mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Big refactor
This commit is contained in:
@@ -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<string> {
|
||||
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<string | ReadableStream | StreamingChatResponse> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
try {
|
||||
const apiKey = getRotatingApiKey('anthropic')
|
||||
|
||||
const response = await executeProviderRequest('anthropic', {
|
||||
model: 'claude-3-haiku-20240307', // Use faster, cheaper model for title generation
|
||||
systemPrompt:
|
||||
'You are a helpful assistant that generates concise, descriptive titles for chat conversations. Create a title that captures the main topic or question being discussed. Keep it under 50 characters and make it specific and clear.',
|
||||
context: `Generate a concise title for a conversation that starts with this user message: "${userMessage}"
|
||||
|
||||
Return only the title text, nothing else.`,
|
||||
temperature: 0.3,
|
||||
maxTokens: 50,
|
||||
apiKey,
|
||||
stream: false,
|
||||
})
|
||||
|
||||
// Handle different response types
|
||||
if (typeof response === 'object' && 'content' in response) {
|
||||
return response.content?.trim() || 'New Chat'
|
||||
}
|
||||
|
||||
return 'New Chat'
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate chat title:', error)
|
||||
return 'New Chat' // Fallback title
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
257
apps/sim/app/api/copilot/docs/route.ts
Normal file
257
apps/sim/app/api/copilot/docs/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string | ReadableStream> {
|
||||
// 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) {
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit(e as unknown as React.FormEvent)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className='fixed right-16 bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
|
||||
>
|
||||
<MessageCircle className='h-5 w-5' />
|
||||
<span className='sr-only'>Open Chat</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>Open Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-translate-x-1/2 fixed bottom-16 left-1/2 z-50 w-[50%] min-w-[280px] max-w-[500px] rounded-2xl border bg-background shadow-lg'>
|
||||
<form onSubmit={handleSubmit} className='flex items-center gap-2 p-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setIsOpen(false)}
|
||||
className='h-8 w-8 rounded-full text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close Chat</span>
|
||||
</Button>
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => 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'
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 rounded-full text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
>
|
||||
<Send className='h-4 w-4' />
|
||||
<span className='sr-only'>Send message</span>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<CopilotRef, CopilotProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [messages, setMessages] = useState<CopilotMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [currentChat, setCurrentChat] = useState<CopilotChat | null>(null)
|
||||
const [chats, setChats] = useState<CopilotChat[]>([])
|
||||
const [loadingChats, setLoadingChats] = useState(false)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(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<CopilotRef, CopilotProps>(
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Load chats for current workflow
|
||||
const loadChats = useCallback(async () => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
setLoadingChats(true)
|
||||
try {
|
||||
const result = await listChats(activeWorkflowId)
|
||||
if (result.success) {
|
||||
setChats(result.chats)
|
||||
// If no current chat and we have chats, select the most recent one
|
||||
if (!currentChat && result.chats.length > 0) {
|
||||
await selectChat(result.chats[0])
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to load chats:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading chats:', error)
|
||||
} finally {
|
||||
setLoadingChats(false)
|
||||
}
|
||||
}, [activeWorkflowId, currentChat])
|
||||
|
||||
// Select a specific chat and load its messages
|
||||
const selectChat = useCallback(async (chat: CopilotChat) => {
|
||||
try {
|
||||
const result = await getChat(chat.id)
|
||||
if (result.success && result.chat) {
|
||||
setCurrentChat(result.chat)
|
||||
setMessages(result.chat.messages || [])
|
||||
logger.info(`Loaded chat: ${chat.title || 'Untitled'}`)
|
||||
} else {
|
||||
logger.error('Failed to load chat:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading chat:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start a new chat
|
||||
const startNewChat = useCallback(() => {
|
||||
setCurrentChat(null)
|
||||
setMessages([])
|
||||
logger.info('Started new chat')
|
||||
}, [])
|
||||
|
||||
// Delete a chat
|
||||
// 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<CopilotRef, CopilotProps>(
|
||||
})
|
||||
|
||||
// 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<CopilotRef, CopilotProps>(
|
||||
})
|
||||
}
|
||||
|
||||
// Basic markdown processing for better formatting
|
||||
// Basic markdown processing
|
||||
processedContent = processedContent
|
||||
// Handle code blocks
|
||||
.replace(
|
||||
/```(\w+)?\n([\s\S]*?)```/g,
|
||||
'<pre class="bg-muted p-3 rounded-lg overflow-x-auto my-3 text-sm"><code>$2</code></pre>'
|
||||
)
|
||||
// Handle inline code
|
||||
.replace(
|
||||
/`([^`]+)`/g,
|
||||
'<code class="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">$1</code>'
|
||||
)
|
||||
// Handle bold text
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
// Handle italic text
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
// Handle headers
|
||||
.replace(/^### (.*$)/gm, '<h3 class="font-semibold text-base mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.*$)/gm, '<h2 class="font-semibold text-lg mt-4 mb-2">$1</h2>')
|
||||
.replace(/^# (.*$)/gm, '<h1 class="font-bold text-xl mt-4 mb-3">$1</h1>')
|
||||
// Handle unordered lists
|
||||
.replace(/^\* (.*$)/gm, '<li class="ml-4">• $1</li>')
|
||||
.replace(/^- (.*$)/gm, '<li class="ml-4">• $1</li>')
|
||||
// Handle line breaks (reduce spacing)
|
||||
.replace(/\n\n+/g, '</p><p class="mt-2">')
|
||||
.replace(/\n/g, '<br>')
|
||||
|
||||
// Wrap in paragraph tags if not already wrapped
|
||||
// Wrap in paragraph tags if needed
|
||||
if (
|
||||
!processedContent.includes('<p>') &&
|
||||
!processedContent.includes('<h1>') &&
|
||||
@@ -455,10 +278,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
// 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<CopilotRef, CopilotProps>(
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={startNewChat}
|
||||
className='ml-2 h-8 w-8 p-0'
|
||||
onClick={handleStartNewChat}
|
||||
className='h-8 w-8 p-0'
|
||||
title='New Chat'
|
||||
>
|
||||
<MessageSquarePlus className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className='mt-2 rounded-md bg-destructive/10 p-2 text-destructive text-sm'>
|
||||
{error}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearError}
|
||||
className='ml-2 h-auto p-1 text-destructive'
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className='flex-1' ref={scrollAreaRef}>
|
||||
{/* Messages area */}
|
||||
<ScrollArea ref={scrollAreaRef} className='flex-1'>
|
||||
{messages.length === 0 ? (
|
||||
<div className='flex h-full flex-col items-center justify-center p-8 text-center'>
|
||||
<Bot className='mb-4 h-12 w-12 text-muted-foreground' />
|
||||
<h3 className='mb-2 font-medium text-sm'>Welcome to Documentation Copilot</h3>
|
||||
<p className='mb-4 max-w-xs text-muted-foreground text-xs'>
|
||||
Ask me anything about Sim Studio features, workflows, tools, or how to get
|
||||
started.
|
||||
</p>
|
||||
<div className='space-y-2 text-left'>
|
||||
<div className='text-muted-foreground text-xs'>Try asking:</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I create a workflow?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"What tools are available?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I deploy my workflow?"
|
||||
<div className='flex h-full flex-col items-center justify-center px-4 py-10'>
|
||||
<div className='space-y-4 text-center'>
|
||||
<Bot className='mx-auto h-12 w-12 text-muted-foreground' />
|
||||
<div className='space-y-2'>
|
||||
<h3 className='font-medium text-lg'>Welcome to Documentation Copilot</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Ask me anything about Sim Studio features, workflows, tools, or how to get
|
||||
started.
|
||||
</p>
|
||||
</div>
|
||||
<div className='mx-auto max-w-xs space-y-2 text-left'>
|
||||
<div className='text-muted-foreground text-xs'>Try asking:</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I create a workflow?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"What tools are available?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I deploy my workflow?"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-1'>{messages.map(renderMessage)}</div>
|
||||
messages.map(renderMessage)
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
{/* Input area */}
|
||||
<div className='border-t p-4'>
|
||||
<form onSubmit={handleSubmit} className='flex gap-2'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder='Ask about Sim Studio documentation...'
|
||||
disabled={isLoading}
|
||||
disabled={isSendingMessage}
|
||||
className='flex-1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
size='icon'
|
||||
disabled={!input.trim() || isLoading}
|
||||
disabled={isSendingMessage}
|
||||
className='h-10 w-10'
|
||||
>
|
||||
{isLoading ? (
|
||||
{isSendingMessage ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Send className='h-4 w-4' />
|
||||
@@ -594,11 +432,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
setCopilotMessage={(message) => onFullscreenInputChange?.(message)}
|
||||
messages={modalMessages}
|
||||
onSendMessage={handleModalSendMessage}
|
||||
isLoading={isLoading}
|
||||
isLoading={isSendingMessage}
|
||||
chats={chats}
|
||||
currentChat={currentChat}
|
||||
onSelectChat={selectChat}
|
||||
onStartNewChat={startNewChat}
|
||||
onStartNewChat={handleStartNewChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -2,41 +2,49 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('CopilotAPI')
|
||||
|
||||
export interface CopilotChat {
|
||||
id: string
|
||||
title: string | null
|
||||
model: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
messageCount: number
|
||||
messages?: CopilotMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Message interface for copilot conversations
|
||||
*/
|
||||
export interface CopilotMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: string
|
||||
citations?: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
similarity?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CreateChatRequest {
|
||||
workflowId: string
|
||||
title?: string
|
||||
model?: string
|
||||
initialMessage?: string
|
||||
/**
|
||||
* Chat interface for copilot conversations
|
||||
*/
|
||||
export interface CopilotChat {
|
||||
id: string
|
||||
title: string | null
|
||||
model: string
|
||||
messages: CopilotMessage[]
|
||||
messageCount: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface UpdateChatRequest {
|
||||
title?: string
|
||||
messages?: CopilotMessage[]
|
||||
model?: string
|
||||
/**
|
||||
* Request interface for sending messages
|
||||
*/
|
||||
export interface SendMessageRequest {
|
||||
message: string
|
||||
chatId?: string
|
||||
workflowId?: string
|
||||
createNewChat?: boolean
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Request interface for docs queries
|
||||
*/
|
||||
export interface DocsQueryRequest {
|
||||
query: string
|
||||
topK?: number
|
||||
@@ -49,58 +57,27 @@ export interface DocsQueryRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* List chats for a specific workflow
|
||||
* Create a new copilot chat
|
||||
*/
|
||||
export async function listChats(
|
||||
export async function createChat(
|
||||
workflowId: string,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
options: {
|
||||
title?: string
|
||||
initialMessage?: string
|
||||
} = {}
|
||||
): Promise<{
|
||||
success: boolean
|
||||
chats: CopilotChat[]
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
workflowId,
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/copilot/chats?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to list chats')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chats: data.chats || [],
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to list chats:', error)
|
||||
return {
|
||||
success: false,
|
||||
chats: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat
|
||||
*/
|
||||
export async function createChat(request: CreateChatRequest): Promise<{
|
||||
success: boolean
|
||||
chat?: CopilotChat
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/copilot/chats', {
|
||||
method: 'POST',
|
||||
const response = await fetch('/api/copilot', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
...options,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@@ -122,6 +99,48 @@ export async function createChat(request: CreateChatRequest): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List chats for a specific workflow
|
||||
*/
|
||||
export async function listChats(
|
||||
workflowId: string,
|
||||
options: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
} = {}
|
||||
): Promise<{
|
||||
success: boolean
|
||||
chats: CopilotChat[]
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
workflowId,
|
||||
limit: (options.limit || 50).toString(),
|
||||
offset: (options.offset || 0).toString(),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/copilot?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to list chats')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chats: data.chats || [],
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to list chats:', error)
|
||||
return {
|
||||
success: false,
|
||||
chats: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific chat with full message history
|
||||
*/
|
||||
@@ -131,7 +150,7 @@ export async function getChat(chatId: string): Promise<{
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/chats/${chatId}`)
|
||||
const response = await fetch(`/api/copilot?chatId=${chatId}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -151,43 +170,6 @@ export async function getChat(chatId: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a chat
|
||||
*/
|
||||
export async function updateChat(
|
||||
chatId: string,
|
||||
request: UpdateChatRequest
|
||||
): Promise<{
|
||||
success: boolean
|
||||
chat?: CopilotChat
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/chats/${chatId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update chat')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chat: data.chat,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update chat:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chat
|
||||
*/
|
||||
@@ -196,7 +178,7 @@ export async function deleteChat(chatId: string): Promise<{
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/chats/${chatId}`, {
|
||||
const response = await fetch(`/api/copilot?chatId=${chatId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
@@ -206,9 +188,7 @@ export async function deleteChat(chatId: string): Promise<{
|
||||
throw new Error(data.error || 'Failed to delete chat')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete chat:', error)
|
||||
return {
|
||||
@@ -219,22 +199,22 @@ export async function deleteChat(chatId: string): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message using the docs RAG API with chat context
|
||||
* Send a message using the unified copilot API
|
||||
*/
|
||||
export async function sendMessage(request: DocsQueryRequest): Promise<{
|
||||
export async function sendMessage(request: SendMessageRequest): Promise<{
|
||||
success: boolean
|
||||
response?: string
|
||||
chatId?: string
|
||||
sources?: Array<{
|
||||
citations?: Array<{
|
||||
id: number
|
||||
title: string
|
||||
document: string
|
||||
link: string
|
||||
similarity: number
|
||||
url: string
|
||||
similarity?: number
|
||||
}>
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/docs/ask', {
|
||||
const response = await fetch('/api/copilot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
@@ -250,7 +230,7 @@ export async function sendMessage(request: DocsQueryRequest): Promise<{
|
||||
success: true,
|
||||
response: data.response,
|
||||
chatId: data.chatId,
|
||||
sources: data.sources,
|
||||
citations: data.citations,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message:', error)
|
||||
@@ -262,21 +242,16 @@ export async function sendMessage(request: DocsQueryRequest): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming message using the new copilot chat API
|
||||
* Send a streaming message using the unified copilot API
|
||||
*/
|
||||
export async function sendStreamingMessage(request: {
|
||||
message: string
|
||||
chatId?: string
|
||||
workflowId?: string
|
||||
createNewChat?: boolean
|
||||
}): Promise<{
|
||||
export async function sendStreamingMessage(request: SendMessageRequest): Promise<{
|
||||
success: boolean
|
||||
stream?: ReadableStream
|
||||
chatId?: string
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/copilot/chat', {
|
||||
const response = await fetch('/api/copilot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, stream: true }),
|
||||
@@ -303,3 +278,84 @@ export async function sendStreamingMessage(request: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message using the docs RAG API with chat context
|
||||
*/
|
||||
export async function sendDocsMessage(request: DocsQueryRequest): Promise<{
|
||||
success: boolean
|
||||
response?: string
|
||||
chatId?: string
|
||||
sources?: Array<{
|
||||
title: string
|
||||
document: string
|
||||
link: string
|
||||
similarity: number
|
||||
}>
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/copilot/docs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send message')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: data.response,
|
||||
chatId: data.chatId,
|
||||
sources: data.sources,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send docs message:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming docs message
|
||||
*/
|
||||
export async function sendStreamingDocsMessage(request: DocsQueryRequest): Promise<{
|
||||
success: boolean
|
||||
stream?: ReadableStream
|
||||
chatId?: string
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/copilot/docs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, stream: true }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to send streaming docs message')
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body received')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stream: response.body,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send streaming docs message:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
237
apps/sim/lib/copilot/config.ts
Normal file
237
apps/sim/lib/copilot/config.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getProviderDefaultModel } from '@/providers/models'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
|
||||
const logger = createLogger('CopilotConfig')
|
||||
|
||||
/**
|
||||
* Copilot configuration interface
|
||||
*/
|
||||
export interface CopilotConfig {
|
||||
// Chat LLM configuration
|
||||
chat: {
|
||||
defaultProvider: ProviderId
|
||||
defaultModel: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
systemPrompt: string
|
||||
}
|
||||
// RAG (documentation search) LLM configuration
|
||||
rag: {
|
||||
defaultProvider: ProviderId
|
||||
defaultModel: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
embeddingModel: string
|
||||
maxSources: number
|
||||
similarityThreshold: number
|
||||
}
|
||||
// General configuration
|
||||
general: {
|
||||
streamingEnabled: boolean
|
||||
maxConversationHistory: number
|
||||
titleGenerationModel: string // Lighter model for generating chat titles
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default copilot configuration
|
||||
* Uses Claude 4 Sonnet as requested
|
||||
*/
|
||||
export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
|
||||
chat: {
|
||||
defaultProvider: 'anthropic',
|
||||
defaultModel: 'claude-3-7-sonnet-latest',
|
||||
temperature: 0.1,
|
||||
maxTokens: 4000,
|
||||
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.`
|
||||
},
|
||||
rag: {
|
||||
defaultProvider: 'anthropic',
|
||||
defaultModel: 'claude-3-7-sonnet-latest',
|
||||
temperature: 0.1,
|
||||
maxTokens: 2000,
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
maxSources: 5,
|
||||
similarityThreshold: 0.7
|
||||
},
|
||||
general: {
|
||||
streamingEnabled: true,
|
||||
maxConversationHistory: 10,
|
||||
titleGenerationModel: 'claude-3-haiku-20240307' // Faster model for titles
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get copilot configuration with environment variable overrides
|
||||
*/
|
||||
export function getCopilotConfig(): CopilotConfig {
|
||||
const config = { ...DEFAULT_COPILOT_CONFIG }
|
||||
|
||||
// Allow environment variable overrides
|
||||
try {
|
||||
// Chat configuration overrides
|
||||
if (process.env.COPILOT_CHAT_PROVIDER) {
|
||||
config.chat.defaultProvider = process.env.COPILOT_CHAT_PROVIDER as ProviderId
|
||||
}
|
||||
if (process.env.COPILOT_CHAT_MODEL) {
|
||||
config.chat.defaultModel = process.env.COPILOT_CHAT_MODEL
|
||||
}
|
||||
if (process.env.COPILOT_CHAT_TEMPERATURE) {
|
||||
config.chat.temperature = Number.parseFloat(process.env.COPILOT_CHAT_TEMPERATURE)
|
||||
}
|
||||
if (process.env.COPILOT_CHAT_MAX_TOKENS) {
|
||||
config.chat.maxTokens = Number.parseInt(process.env.COPILOT_CHAT_MAX_TOKENS)
|
||||
}
|
||||
|
||||
// RAG configuration overrides
|
||||
if (process.env.COPILOT_RAG_PROVIDER) {
|
||||
config.rag.defaultProvider = process.env.COPILOT_RAG_PROVIDER as ProviderId
|
||||
}
|
||||
if (process.env.COPILOT_RAG_MODEL) {
|
||||
config.rag.defaultModel = process.env.COPILOT_RAG_MODEL
|
||||
}
|
||||
if (process.env.COPILOT_RAG_TEMPERATURE) {
|
||||
config.rag.temperature = Number.parseFloat(process.env.COPILOT_RAG_TEMPERATURE)
|
||||
}
|
||||
if (process.env.COPILOT_RAG_MAX_TOKENS) {
|
||||
config.rag.maxTokens = Number.parseInt(process.env.COPILOT_RAG_MAX_TOKENS)
|
||||
}
|
||||
if (process.env.COPILOT_RAG_MAX_SOURCES) {
|
||||
config.rag.maxSources = Number.parseInt(process.env.COPILOT_RAG_MAX_SOURCES)
|
||||
}
|
||||
if (process.env.COPILOT_RAG_SIMILARITY_THRESHOLD) {
|
||||
config.rag.similarityThreshold = Number.parseFloat(process.env.COPILOT_RAG_SIMILARITY_THRESHOLD)
|
||||
}
|
||||
|
||||
// General configuration overrides
|
||||
if (process.env.COPILOT_STREAMING_ENABLED) {
|
||||
config.general.streamingEnabled = process.env.COPILOT_STREAMING_ENABLED === 'true'
|
||||
}
|
||||
if (process.env.COPILOT_MAX_CONVERSATION_HISTORY) {
|
||||
config.general.maxConversationHistory = Number.parseInt(process.env.COPILOT_MAX_CONVERSATION_HISTORY)
|
||||
}
|
||||
|
||||
logger.info('Copilot configuration loaded', {
|
||||
chatProvider: config.chat.defaultProvider,
|
||||
chatModel: config.chat.defaultModel,
|
||||
ragProvider: config.rag.defaultProvider,
|
||||
ragModel: config.rag.defaultModel,
|
||||
streamingEnabled: config.general.streamingEnabled
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Error applying environment variable overrides, using defaults', { error })
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model to use for a specific copilot function
|
||||
*/
|
||||
export function getCopilotModel(type: 'chat' | 'rag' | 'title'): { provider: ProviderId; model: string } {
|
||||
const config = getCopilotConfig()
|
||||
|
||||
switch (type) {
|
||||
case 'chat':
|
||||
return {
|
||||
provider: config.chat.defaultProvider,
|
||||
model: config.chat.defaultModel
|
||||
}
|
||||
case 'rag':
|
||||
return {
|
||||
provider: config.rag.defaultProvider,
|
||||
model: config.rag.defaultModel
|
||||
}
|
||||
case 'title':
|
||||
return {
|
||||
provider: config.chat.defaultProvider, // Use same provider as chat
|
||||
model: config.general.titleGenerationModel
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown copilot model type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a provider/model combination is available
|
||||
*/
|
||||
export function validateCopilotConfig(config: CopilotConfig): { isValid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
|
||||
// Validate chat provider/model
|
||||
try {
|
||||
const chatDefaultModel = getProviderDefaultModel(config.chat.defaultProvider)
|
||||
if (!chatDefaultModel) {
|
||||
errors.push(`Chat provider '${config.chat.defaultProvider}' not found`)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Invalid chat provider: ${config.chat.defaultProvider}`)
|
||||
}
|
||||
|
||||
// Validate RAG provider/model
|
||||
try {
|
||||
const ragDefaultModel = getProviderDefaultModel(config.rag.defaultProvider)
|
||||
if (!ragDefaultModel) {
|
||||
errors.push(`RAG provider '${config.rag.defaultProvider}' not found`)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Invalid RAG provider: ${config.rag.defaultProvider}`)
|
||||
}
|
||||
|
||||
// Validate configuration values
|
||||
if (config.chat.temperature < 0 || config.chat.temperature > 2) {
|
||||
errors.push('Chat temperature must be between 0 and 2')
|
||||
}
|
||||
if (config.rag.temperature < 0 || config.rag.temperature > 2) {
|
||||
errors.push('RAG temperature must be between 0 and 2')
|
||||
}
|
||||
if (config.chat.maxTokens < 1 || config.chat.maxTokens > 100000) {
|
||||
errors.push('Chat maxTokens must be between 1 and 100000')
|
||||
}
|
||||
if (config.rag.maxTokens < 1 || config.rag.maxTokens > 100000) {
|
||||
errors.push('RAG maxTokens must be between 1 and 100000')
|
||||
}
|
||||
if (config.rag.maxSources < 1 || config.rag.maxSources > 20) {
|
||||
errors.push('RAG maxSources must be between 1 and 20')
|
||||
}
|
||||
if (config.rag.similarityThreshold < 0 || config.rag.similarityThreshold > 1) {
|
||||
errors.push('RAG similarityThreshold must be between 0 and 1')
|
||||
}
|
||||
if (config.general.maxConversationHistory < 1 || config.general.maxConversationHistory > 50) {
|
||||
errors.push('General maxConversationHistory must be between 1 and 50')
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
716
apps/sim/lib/copilot/service.ts
Normal file
716
apps/sim/lib/copilot/service.ts
Normal file
@@ -0,0 +1,716 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getApiKey } from '@/providers/utils'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { db } from '@/db'
|
||||
import { copilotChats, embedding, knowledgeBase, document } from '@/db/schema'
|
||||
import type { ProviderId, ProviderToolConfig } from '@/providers/types'
|
||||
import { getCopilotConfig, getCopilotModel } from './config'
|
||||
|
||||
const logger = createLogger('CopilotService')
|
||||
|
||||
/**
|
||||
* Message interface for copilot conversations
|
||||
*/
|
||||
export interface CopilotMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: string
|
||||
citations?: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
similarity?: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat interface for copilot conversations
|
||||
*/
|
||||
export interface CopilotChat {
|
||||
id: string
|
||||
title: string | null
|
||||
model: string
|
||||
messages: CopilotMessage[]
|
||||
messageCount: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Request interface for sending messages
|
||||
*/
|
||||
export interface SendMessageRequest {
|
||||
message: string
|
||||
chatId?: string
|
||||
workflowId?: string
|
||||
createNewChat?: boolean
|
||||
stream?: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Response interface for sending messages
|
||||
*/
|
||||
export interface SendMessageResponse {
|
||||
content: string
|
||||
chatId?: string
|
||||
citations?: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
similarity?: number
|
||||
}>
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a chat title using LLM
|
||||
*/
|
||||
export 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}"\n\nReturn 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'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search documentation using RAG
|
||||
*/
|
||||
export async function searchDocumentation(
|
||||
query: string,
|
||||
options: {
|
||||
topK?: number
|
||||
threshold?: number
|
||||
} = {}
|
||||
): Promise<Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
content: string
|
||||
similarity: number
|
||||
}>> {
|
||||
const { generateEmbeddings } = require('@/app/api/knowledge/utils')
|
||||
const { docsEmbeddings } = require('@/db/schema')
|
||||
const { sql } = require('drizzle-orm')
|
||||
|
||||
const config = getCopilotConfig()
|
||||
const { topK = config.rag.maxSources, threshold = config.rag.similarityThreshold } = options
|
||||
|
||||
try {
|
||||
logger.info('Documentation search requested', { query, topK, threshold })
|
||||
|
||||
// Generate embedding for the query
|
||||
const embeddings = await generateEmbeddings([query])
|
||||
const queryEmbedding = embeddings[0]
|
||||
|
||||
if (!queryEmbedding || queryEmbedding.length === 0) {
|
||||
logger.warn('Failed to generate query embedding')
|
||||
return []
|
||||
}
|
||||
|
||||
// Search docs embeddings using vector similarity
|
||||
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<number>`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`,
|
||||
})
|
||||
.from(docsEmbeddings)
|
||||
.orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`)
|
||||
.limit(topK)
|
||||
|
||||
// Filter by similarity threshold
|
||||
const filteredResults = results.filter(result => result.similarity >= threshold)
|
||||
|
||||
logger.info(`Found ${filteredResults.length} relevant documentation chunks`, {
|
||||
totalResults: results.length,
|
||||
afterFiltering: filteredResults.length,
|
||||
threshold
|
||||
})
|
||||
|
||||
return filteredResults.map((result, index) => ({
|
||||
id: index + 1,
|
||||
title: String(result.headerText || 'Untitled Section'),
|
||||
url: String(result.sourceLink || '#'),
|
||||
content: String(result.chunkText || ''),
|
||||
similarity: result.similarity
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Failed to search documentation:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate documentation-based response using RAG
|
||||
*/
|
||||
export async function generateDocsResponse(
|
||||
query: string,
|
||||
conversationHistory: CopilotMessage[] = [],
|
||||
options: {
|
||||
stream?: boolean
|
||||
topK?: number
|
||||
provider?: string
|
||||
model?: string
|
||||
workflowId?: string
|
||||
requestId?: string
|
||||
} = {}
|
||||
): Promise<{
|
||||
response: string | ReadableStream
|
||||
sources: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
similarity: number
|
||||
}>
|
||||
}> {
|
||||
const config = getCopilotConfig()
|
||||
const { provider, model } = getCopilotModel('rag')
|
||||
const {
|
||||
stream = config.general.streamingEnabled,
|
||||
topK = config.rag.maxSources,
|
||||
provider: overrideProvider,
|
||||
model: overrideModel
|
||||
} = options
|
||||
|
||||
const selectedProvider = overrideProvider || provider
|
||||
const selectedModel = overrideModel || model
|
||||
|
||||
try {
|
||||
let apiKey: string
|
||||
try {
|
||||
// Use rotating key directly for hosted providers
|
||||
if ((selectedProvider === 'openai' || selectedProvider === 'anthropic')) {
|
||||
const { getRotatingApiKey } = require('@/lib/utils')
|
||||
apiKey = getRotatingApiKey(selectedProvider)
|
||||
} else {
|
||||
apiKey = getApiKey(selectedProvider, selectedModel)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get API key for docs response (${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.`)
|
||||
}
|
||||
|
||||
// Search documentation
|
||||
const searchResults = await searchDocumentation(query, { topK })
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
const fallbackResponse = "I couldn't find any relevant documentation for your question. Please try rephrasing your query or check if you're asking about a feature that exists in Sim Studio."
|
||||
return {
|
||||
response: fallbackResponse,
|
||||
sources: []
|
||||
}
|
||||
}
|
||||
|
||||
// Format search results as context with numbered sources
|
||||
const context = searchResults
|
||||
.map((result, index) => {
|
||||
return `[${index + 1}] ${result.title}
|
||||
Document: ${result.title}
|
||||
URL: ${result.url}
|
||||
Content: ${result.content}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Build conversation context if we have history
|
||||
let conversationContext = ''
|
||||
if (conversationHistory.length > 0) {
|
||||
conversationContext = '\n\nConversation History:\n'
|
||||
conversationHistory.slice(-config.general.maxConversationHistory).forEach((msg) => {
|
||||
const role = msg.role === 'user' ? 'Human' : 'Assistant'
|
||||
conversationContext += `${role}: ${msg.content}\n`
|
||||
})
|
||||
conversationContext += '\n'
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a helpful assistant that answers questions about Sim Studio documentation. You are having a conversation with the user, so refer to the conversation history when relevant.
|
||||
|
||||
IMPORTANT: Use inline citations strategically and sparingly. When referencing information from the sources, include the citation number in curly braces like {cite:1}, {cite:2}, etc.
|
||||
|
||||
Citation Guidelines:
|
||||
- Cite each source only ONCE at the specific header or topic that relates to that source
|
||||
- Do NOT repeatedly cite the same source throughout your response
|
||||
- Place citations directly after the header or concept that the source specifically addresses
|
||||
- If multiple sources support the same specific topic, cite them together like {cite:1}{cite:2}{cite:3}
|
||||
- Each citation should be placed at the relevant header/topic it supports, not grouped at the beginning
|
||||
- Avoid cluttering the text with excessive citations
|
||||
|
||||
Content Guidelines:
|
||||
- Answer the user's question accurately using the provided documentation
|
||||
- Consider the conversation history and refer to previous messages when relevant
|
||||
- Format your response in clean, readable markdown
|
||||
- Use bullet points, code blocks, and headers where appropriate
|
||||
- If the question cannot be answered from the context, say so clearly
|
||||
- Be conversational but precise
|
||||
- NEVER include object representations like "[object Object]" - always use proper text
|
||||
- When mentioning tool names, use their actual names from the documentation
|
||||
|
||||
The sources are numbered [1] through [${searchResults.length}] in the context below.`
|
||||
|
||||
const userPrompt = `${conversationContext}Current Question: ${query}
|
||||
|
||||
Documentation Context:
|
||||
${context}`
|
||||
|
||||
logger.info(`Generating docs response using provider: ${selectedProvider}, model: ${selectedModel}`)
|
||||
|
||||
const response = await executeProviderRequest(selectedProvider, {
|
||||
model: selectedModel,
|
||||
systemPrompt,
|
||||
context: userPrompt,
|
||||
temperature: config.rag.temperature,
|
||||
maxTokens: config.rag.maxTokens,
|
||||
apiKey,
|
||||
stream,
|
||||
})
|
||||
|
||||
// Format sources for response
|
||||
const sources = searchResults.map((result) => ({
|
||||
id: result.id,
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
similarity: Math.round(result.similarity * 100) / 100,
|
||||
}))
|
||||
|
||||
// Handle different response types
|
||||
if (response instanceof ReadableStream) {
|
||||
return { response, sources }
|
||||
}
|
||||
|
||||
if ('stream' in response && 'execution' in response) {
|
||||
// Handle StreamingExecution for providers like Anthropic
|
||||
if (stream) {
|
||||
return { response: response.stream, sources }
|
||||
}
|
||||
throw new Error('Unexpected streaming execution response when non-streaming was requested')
|
||||
}
|
||||
|
||||
// At this point, we have a ProviderResponse
|
||||
const content = response.content || 'Sorry, I could not generate a response.'
|
||||
|
||||
// Clean up any object serialization artifacts
|
||||
const cleanedContent = content
|
||||
.replace(/\[object Object\],?/g, '') // Remove [object Object] artifacts
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim()
|
||||
|
||||
return {
|
||||
response: cleanedContent,
|
||||
sources
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate docs response:', error)
|
||||
throw new Error(`Failed to generate docs response: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate chat response using LLM with optional documentation search
|
||||
*/
|
||||
export async function generateChatResponse(
|
||||
message: string,
|
||||
conversationHistory: CopilotMessage[] = [],
|
||||
options: {
|
||||
stream?: boolean
|
||||
workflowId?: string
|
||||
requestId?: string
|
||||
} = {}
|
||||
): Promise<string | ReadableStream> {
|
||||
const config = getCopilotConfig()
|
||||
const { provider, model } = getCopilotModel('chat')
|
||||
const { stream = config.general.streamingEnabled } = options
|
||||
|
||||
try {
|
||||
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 chat (${provider} ${model}):`, error)
|
||||
throw new Error(`API key not configured for ${provider}. Please set up API keys for this provider or use a different one.`)
|
||||
}
|
||||
|
||||
// Build conversation context
|
||||
const messages = []
|
||||
|
||||
// Add conversation history (limited by config)
|
||||
const historyLimit = config.general.maxConversationHistory
|
||||
const recentHistory = conversationHistory.slice(-historyLimit)
|
||||
|
||||
for (const msg of recentHistory) {
|
||||
messages.push({
|
||||
role: msg.role as 'user' | 'assistant' | 'system',
|
||||
content: msg.content,
|
||||
})
|
||||
}
|
||||
|
||||
// Add current user message
|
||||
messages.push({
|
||||
role: 'user' as const,
|
||||
content: message,
|
||||
})
|
||||
|
||||
// Define the documentation search tool for the LLM
|
||||
const tools: ProviderToolConfig[] = [
|
||||
{
|
||||
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'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const response = await executeProviderRequest(provider, {
|
||||
model,
|
||||
systemPrompt: config.chat.systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
temperature: config.chat.temperature,
|
||||
maxTokens: config.chat.maxTokens,
|
||||
apiKey,
|
||||
stream,
|
||||
})
|
||||
|
||||
// Handle tool calls if needed (documentation search)
|
||||
if (typeof response === 'object' && 'content' in response) {
|
||||
return response.content || 'Sorry, I could not generate a response.'
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
if (response instanceof ReadableStream) {
|
||||
return 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'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new copilot chat
|
||||
*/
|
||||
export async function createChat(
|
||||
userId: string,
|
||||
workflowId: string,
|
||||
options: {
|
||||
title?: string
|
||||
initialMessage?: string
|
||||
} = {}
|
||||
): Promise<CopilotChat> {
|
||||
const config = getCopilotConfig()
|
||||
const { provider, model } = getCopilotModel('chat')
|
||||
const { title, initialMessage } = options
|
||||
|
||||
try {
|
||||
// Prepare initial messages array
|
||||
const initialMessages: CopilotMessage[] = initialMessage
|
||||
? [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: initialMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
// Create the chat
|
||||
const [newChat] = await db
|
||||
.insert(copilotChats)
|
||||
.values({
|
||||
userId,
|
||||
workflowId,
|
||||
title: title || null, // Will be generated later if null
|
||||
model,
|
||||
messages: initialMessages,
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (!newChat) {
|
||||
throw new Error('Failed to create chat')
|
||||
}
|
||||
|
||||
logger.info(`Created chat ${newChat.id} for user ${userId}`)
|
||||
|
||||
return {
|
||||
id: newChat.id,
|
||||
title: newChat.title,
|
||||
model: newChat.model,
|
||||
messages: Array.isArray(newChat.messages) ? newChat.messages : [],
|
||||
messageCount: Array.isArray(newChat.messages) ? newChat.messages.length : 0,
|
||||
createdAt: newChat.createdAt,
|
||||
updatedAt: newChat.updatedAt,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create chat:', error)
|
||||
throw new Error(`Failed to create chat: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific chat
|
||||
*/
|
||||
export async function getChat(chatId: string, userId: string): Promise<CopilotChat | null> {
|
||||
try {
|
||||
const [chat] = await db
|
||||
.select()
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!chat) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
model: chat.model,
|
||||
messages: Array.isArray(chat.messages) ? chat.messages : [],
|
||||
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get chat:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List chats for a workflow
|
||||
*/
|
||||
export async function listChats(
|
||||
userId: string,
|
||||
workflowId: string,
|
||||
options: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
} = {}
|
||||
): Promise<CopilotChat[]> {
|
||||
const { limit = 50, offset = 0 } = options
|
||||
|
||||
try {
|
||||
const chats = await db
|
||||
.select()
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.userId, userId), eq(copilotChats.workflowId, workflowId)))
|
||||
.orderBy(copilotChats.updatedAt)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return chats.map(chat => ({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
model: chat.model,
|
||||
messages: Array.isArray(chat.messages) ? chat.messages : [],
|
||||
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Failed to list chats:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a chat (add messages, update title, etc.)
|
||||
*/
|
||||
export async function updateChat(
|
||||
chatId: string,
|
||||
userId: string,
|
||||
updates: {
|
||||
title?: string
|
||||
messages?: CopilotMessage[]
|
||||
}
|
||||
): Promise<CopilotChat | null> {
|
||||
try {
|
||||
// Verify the chat exists and belongs to the user
|
||||
const existingChat = await getChat(chatId, userId)
|
||||
if (!existingChat) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (updates.title !== undefined) updateData.title = updates.title
|
||||
if (updates.messages !== undefined) updateData.messages = updates.messages
|
||||
|
||||
// Update the chat
|
||||
const [updatedChat] = await db
|
||||
.update(copilotChats)
|
||||
.set(updateData)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.returning()
|
||||
|
||||
if (!updatedChat) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: updatedChat.id,
|
||||
title: updatedChat.title,
|
||||
model: updatedChat.model,
|
||||
messages: Array.isArray(updatedChat.messages) ? updatedChat.messages : [],
|
||||
messageCount: Array.isArray(updatedChat.messages) ? updatedChat.messages.length : 0,
|
||||
createdAt: updatedChat.createdAt,
|
||||
updatedAt: updatedChat.updatedAt,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update chat:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chat
|
||||
*/
|
||||
export async function deleteChat(chatId: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await db
|
||||
.delete(copilotChats)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.returning({ id: copilotChats.id })
|
||||
|
||||
return result.length > 0
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete chat:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and get a response
|
||||
*/
|
||||
export async function sendMessage(request: SendMessageRequest): Promise<{
|
||||
response: string | ReadableStream
|
||||
chatId?: string
|
||||
citations?: Array<{ id: number; title: string; url: string; similarity?: number }>
|
||||
}> {
|
||||
const { message, chatId, workflowId, createNewChat, stream, userId } = request
|
||||
|
||||
try {
|
||||
// Handle chat context
|
||||
let currentChat: CopilotChat | null = null
|
||||
let conversationHistory: CopilotMessage[] = []
|
||||
|
||||
if (chatId) {
|
||||
// Load existing chat
|
||||
currentChat = await getChat(chatId, userId)
|
||||
if (currentChat) {
|
||||
conversationHistory = currentChat.messages
|
||||
}
|
||||
} else if (createNewChat && workflowId) {
|
||||
// Create new chat
|
||||
currentChat = await createChat(userId, workflowId)
|
||||
}
|
||||
|
||||
// Generate chat response
|
||||
const response = await generateChatResponse(message, conversationHistory, {
|
||||
stream,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
// If we have a chat, update it with the new messages
|
||||
if (currentChat) {
|
||||
const userMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: typeof response === 'string' ? response : '[Streaming Response]',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
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 updateChat(currentChat.id, userId, {
|
||||
title: updatedTitle || undefined,
|
||||
messages: updatedMessages,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
response,
|
||||
chatId: currentChat?.id,
|
||||
citations: [], // Will be populated when RAG is implemented
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
8
apps/sim/stores/copilot/index.ts
Normal file
8
apps/sim/stores/copilot/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useCopilotStore } from './store'
|
||||
export type {
|
||||
CopilotMessage,
|
||||
CopilotChat,
|
||||
CopilotState,
|
||||
CopilotActions,
|
||||
CopilotStore,
|
||||
} from './types'
|
||||
@@ -1,152 +1,442 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useEnvironmentStore } from '../settings/environment/store'
|
||||
import { useWorkflowStore } from '../workflows/workflow/store'
|
||||
import type { CopilotMessage, CopilotStore } from './types'
|
||||
import { calculateBlockPosition, getNextBlockNumber } from './utils'
|
||||
import {
|
||||
createChat,
|
||||
deleteChat as deleteApiChat,
|
||||
getChat,
|
||||
listChats,
|
||||
sendStreamingMessage,
|
||||
sendStreamingDocsMessage,
|
||||
type CopilotChat,
|
||||
type CopilotMessage,
|
||||
} from '@/lib/copilot-api'
|
||||
import type { CopilotStore } from './types'
|
||||
|
||||
const logger = createLogger('CopilotStore')
|
||||
|
||||
/**
|
||||
* Initial state for the copilot store
|
||||
*/
|
||||
const initialState = {
|
||||
currentChat: null,
|
||||
chats: [],
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
isLoadingChats: false,
|
||||
isSendingMessage: false,
|
||||
error: null,
|
||||
workflowId: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot store using the new unified API
|
||||
*/
|
||||
export const useCopilotStore = create<CopilotStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
messages: [],
|
||||
isProcessing: false,
|
||||
error: null,
|
||||
...initialState,
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
try {
|
||||
set({ isProcessing: true, error: null })
|
||||
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const apiKey = useEnvironmentStore.getState().getVariable('OPENAI_API_KEY')
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'OpenAI API key not found. Please add it to your environment variables.'
|
||||
)
|
||||
}
|
||||
|
||||
// User message
|
||||
const newMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
// Format messages for OpenAI API
|
||||
const formattedMessages = [
|
||||
...get().messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
})),
|
||||
{
|
||||
role: newMessage.role,
|
||||
content: newMessage.content,
|
||||
},
|
||||
]
|
||||
|
||||
// Add message to local state first
|
||||
set((state) => ({
|
||||
messages: [...state.messages, newMessage],
|
||||
}))
|
||||
|
||||
const response = await fetch('/api/copilot', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-OpenAI-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: formattedMessages,
|
||||
workflowState: {
|
||||
blocks: workflowStore.blocks,
|
||||
edges: workflowStore.edges,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send message')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Handle any actions returned from the API
|
||||
if (data.actions) {
|
||||
// Process all block additions first to properly calculate positions
|
||||
const blockActions = data.actions.filter((action: any) => action.name === 'addBlock')
|
||||
|
||||
blockActions.forEach((action: any, index: number) => {
|
||||
const { type, name } = action.parameters
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Calculate position based on current blocks and action index
|
||||
const position = calculateBlockPosition(workflowStore.blocks, index)
|
||||
|
||||
// Generate name if not provided
|
||||
const blockName = name || `${type} ${getNextBlockNumber(workflowStore.blocks, type)}`
|
||||
|
||||
workflowStore.addBlock(id, type, blockName, position)
|
||||
})
|
||||
|
||||
// Handle other actions (edges, removals, etc.)
|
||||
const otherActions = data.actions.filter((action: any) => action.name !== 'addBlock')
|
||||
|
||||
otherActions.forEach((action: any) => {
|
||||
switch (action.name) {
|
||||
case 'addEdge': {
|
||||
const { sourceId, targetId, sourceHandle, targetHandle } = action.parameters
|
||||
workflowStore.addEdge({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
type: 'custom',
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'removeBlock': {
|
||||
workflowStore.removeBlock(action.parameters.id)
|
||||
break
|
||||
}
|
||||
case 'removeEdge': {
|
||||
workflowStore.removeEdge(action.parameters.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add assistant's response to chat
|
||||
if (data.message) {
|
||||
set((state) => ({
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: data.message,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Copilot error:', { error })
|
||||
// Set current workflow ID
|
||||
setWorkflowId: (workflowId: string | null) => {
|
||||
const currentWorkflowId = get().workflowId
|
||||
if (currentWorkflowId !== workflowId) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
workflowId,
|
||||
currentChat: null,
|
||||
chats: [],
|
||||
messages: [],
|
||||
error: null,
|
||||
})
|
||||
} finally {
|
||||
set({ isProcessing: false })
|
||||
|
||||
// Load chats for the new workflow
|
||||
if (workflowId) {
|
||||
get().loadChats()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearCopilot: () => set({ messages: [], error: null }),
|
||||
setError: (error) => set({ error }),
|
||||
// Load chats for current workflow
|
||||
loadChats: async () => {
|
||||
const { workflowId } = get()
|
||||
if (!workflowId) {
|
||||
logger.warn('Cannot load chats: no workflow ID set')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoadingChats: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await listChats(workflowId)
|
||||
|
||||
if (result.success) {
|
||||
set({
|
||||
chats: result.chats,
|
||||
isLoadingChats: false,
|
||||
})
|
||||
|
||||
// If no current chat and we have chats, optionally select the most recent one
|
||||
const { currentChat } = get()
|
||||
if (!currentChat && result.chats.length > 0) {
|
||||
// Auto-select most recent chat
|
||||
await get().selectChat(result.chats[0])
|
||||
}
|
||||
|
||||
logger.info(`Loaded ${result.chats.length} chats for workflow ${workflowId}`)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load chats')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load chats:', error)
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load chats',
|
||||
isLoadingChats: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Select a specific chat
|
||||
selectChat: async (chat: CopilotChat) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await getChat(chat.id)
|
||||
|
||||
if (result.success && result.chat) {
|
||||
set({
|
||||
currentChat: result.chat,
|
||||
messages: result.chat.messages,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
logger.info(`Selected chat: ${result.chat.title || 'Untitled'}`)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load chat')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to select chat:', error)
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load chat',
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Create a new chat
|
||||
createNewChat: async (options = {}) => {
|
||||
const { workflowId } = get()
|
||||
if (!workflowId) {
|
||||
logger.warn('Cannot create chat: no workflow ID set')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await createChat(workflowId, options)
|
||||
|
||||
if (result.success && result.chat) {
|
||||
set({
|
||||
currentChat: result.chat,
|
||||
messages: result.chat.messages,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
// Reload chats to include the new one
|
||||
await get().loadChats()
|
||||
|
||||
logger.info(`Created new chat: ${result.chat.id}`)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to create chat')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create new chat:', error)
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create chat',
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a chat
|
||||
deleteChat: async (chatId: string) => {
|
||||
try {
|
||||
const result = await deleteApiChat(chatId)
|
||||
|
||||
if (result.success) {
|
||||
const { currentChat } = get()
|
||||
|
||||
// Remove from chats list
|
||||
set((state) => ({
|
||||
chats: state.chats.filter((chat) => chat.id !== chatId),
|
||||
}))
|
||||
|
||||
// If this was the current chat, clear it
|
||||
if (currentChat?.id === chatId) {
|
||||
set({
|
||||
currentChat: null,
|
||||
messages: [],
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Deleted chat: ${chatId}`)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete chat')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete chat:', error)
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete chat',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Send a regular message
|
||||
sendMessage: async (message: string, options = {}) => {
|
||||
const { workflowId, currentChat } = get()
|
||||
const { stream = true } = options
|
||||
|
||||
if (!workflowId) {
|
||||
logger.warn('Cannot send message: no workflow ID set')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isSendingMessage: true, error: null })
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Add placeholder for streaming response
|
||||
const streamingMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: [...state.messages, userMessage, streamingMessage],
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await sendStreamingMessage({
|
||||
message,
|
||||
chatId: currentChat?.id,
|
||||
workflowId,
|
||||
createNewChat: !currentChat,
|
||||
stream,
|
||||
})
|
||||
|
||||
if (result.success && result.stream) {
|
||||
await get().handleStreamingResponse(result.stream, streamingMessage.id)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to send message')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message:', error)
|
||||
|
||||
// Replace streaming message with error
|
||||
const errorMessage: CopilotMessage = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error while processing your message. Please try again.',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === streamingMessage.id ? errorMessage : msg
|
||||
),
|
||||
error: error instanceof Error ? error.message : 'Failed to send message',
|
||||
isSendingMessage: false,
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
// Send a docs RAG message
|
||||
sendDocsMessage: async (query: string, options = {}) => {
|
||||
const { workflowId, currentChat } = get()
|
||||
const { stream = true, topK = 5 } = options
|
||||
|
||||
if (!workflowId) {
|
||||
logger.warn('Cannot send docs message: no workflow ID set')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isSendingMessage: true, error: null })
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Add placeholder for streaming response
|
||||
const streamingMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: [...state.messages, userMessage, streamingMessage],
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await sendStreamingDocsMessage({
|
||||
query,
|
||||
topK,
|
||||
chatId: currentChat?.id,
|
||||
workflowId,
|
||||
createNewChat: !currentChat,
|
||||
stream,
|
||||
})
|
||||
|
||||
if (result.success && result.stream) {
|
||||
await get().handleStreamingResponse(result.stream, streamingMessage.id)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to send docs message')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send docs message:', error)
|
||||
|
||||
// Replace streaming message with 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(),
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === streamingMessage.id ? errorMessage : msg
|
||||
),
|
||||
error: error instanceof Error ? error.message : 'Failed to send docs message',
|
||||
isSendingMessage: false,
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
// Handle streaming response (shared by both message types)
|
||||
handleStreamingResponse: async (stream: ReadableStream, messageId: string) => {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let newChatId: string | undefined
|
||||
let responseCitations: Array<{ id: number; title: string; url: string }> = []
|
||||
let streamComplete = false
|
||||
|
||||
try {
|
||||
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 and citations from metadata
|
||||
if (data.chatId) {
|
||||
newChatId = data.chatId
|
||||
}
|
||||
if (data.citations) {
|
||||
responseCitations = data.citations
|
||||
}
|
||||
if (data.sources) {
|
||||
// Convert sources to citations format
|
||||
responseCitations = data.sources.map((source: any, index: number) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
}))
|
||||
}
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
|
||||
// Update the streaming message
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
content: accumulatedContent,
|
||||
citations: responseCitations.length > 0 ? responseCitations : undefined,
|
||||
}
|
||||
: msg
|
||||
),
|
||||
}))
|
||||
} else if (data.type === 'done' || data.type === 'complete') {
|
||||
// Final update
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
content: accumulatedContent,
|
||||
citations: responseCitations.length > 0 ? responseCitations : undefined,
|
||||
}
|
||||
: msg
|
||||
),
|
||||
isSendingMessage: false,
|
||||
}))
|
||||
|
||||
// Handle new chat creation
|
||||
if (newChatId && !get().currentChat) {
|
||||
// Reload chats to get the updated list
|
||||
await get().loadChats()
|
||||
}
|
||||
|
||||
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(`Completed streaming response, content length: ${accumulatedContent.length}`)
|
||||
} catch (error) {
|
||||
logger.error('Error handling streaming response:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Clear current messages
|
||||
clearMessages: () => {
|
||||
set({
|
||||
currentChat: null,
|
||||
messages: [],
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
// Clear error state
|
||||
clearError: () => {
|
||||
set({ error: null })
|
||||
},
|
||||
|
||||
// Reset entire store
|
||||
reset: () => {
|
||||
set(initialState)
|
||||
},
|
||||
}),
|
||||
{ name: 'copilot-store' }
|
||||
)
|
||||
|
||||
@@ -1,20 +1,82 @@
|
||||
/**
|
||||
* Message interface for copilot conversations
|
||||
*/
|
||||
export interface CopilotMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: number
|
||||
timestamp: string
|
||||
citations?: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
similarity?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CopilotState {
|
||||
/**
|
||||
* Chat interface for copilot conversations
|
||||
*/
|
||||
export interface CopilotChat {
|
||||
id: string
|
||||
title: string | null
|
||||
model: string
|
||||
messages: CopilotMessage[]
|
||||
isProcessing: boolean
|
||||
messageCount: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot store state
|
||||
*/
|
||||
export interface CopilotState {
|
||||
// Current active chat
|
||||
currentChat: CopilotChat | null
|
||||
|
||||
// List of available chats for current workflow
|
||||
chats: CopilotChat[]
|
||||
|
||||
// Current messages (from active chat)
|
||||
messages: CopilotMessage[]
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean
|
||||
isLoadingChats: boolean
|
||||
isSendingMessage: boolean
|
||||
|
||||
// Error state
|
||||
error: string | null
|
||||
|
||||
// Current workflow ID (for chat context)
|
||||
workflowId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot store actions
|
||||
*/
|
||||
export interface CopilotActions {
|
||||
sendMessage: (content: string) => Promise<void>
|
||||
clearCopilot: () => void
|
||||
setError: (error: string | null) => void
|
||||
// Chat management
|
||||
setWorkflowId: (workflowId: string | null) => void
|
||||
loadChats: () => Promise<void>
|
||||
selectChat: (chat: CopilotChat) => Promise<void>
|
||||
createNewChat: (options?: { title?: string; initialMessage?: string }) => Promise<void>
|
||||
deleteChat: (chatId: string) => Promise<void>
|
||||
|
||||
// Message handling
|
||||
sendMessage: (message: string, options?: { stream?: boolean }) => Promise<void>
|
||||
sendDocsMessage: (query: string, options?: { stream?: boolean; topK?: number }) => Promise<void>
|
||||
|
||||
// Utility actions
|
||||
clearMessages: () => void
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
|
||||
// Internal helper (not exposed publicly)
|
||||
handleStreamingResponse: (stream: ReadableStream, messageId: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined copilot store interface
|
||||
*/
|
||||
export type CopilotStore = CopilotState & CopilotActions
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Helper function to get the next block number for a given type
|
||||
export const getNextBlockNumber = (blocks: Record<string, any>, type: string) => {
|
||||
const typeBlocks = Object.values(blocks)
|
||||
.filter((block: any) => block.type.toLowerCase() === type.toLowerCase())
|
||||
.map((block: any) => {
|
||||
const match = block.name.match(new RegExp(`${type}\\s*(\\d+)`, 'i'))
|
||||
return match ? Number.parseInt(match[1]) : 0
|
||||
})
|
||||
|
||||
const maxNumber = Math.max(0, ...typeBlocks)
|
||||
return maxNumber + 1
|
||||
}
|
||||
|
||||
// Calculate block position based on existing blocks and current action index
|
||||
export const calculateBlockPosition = (
|
||||
existingBlocks: Record<string, any>,
|
||||
index: number,
|
||||
startX = 100,
|
||||
startY = 100,
|
||||
xSpacing = 500,
|
||||
ySpacing = 150
|
||||
) => {
|
||||
const blocksCount = Object.keys(existingBlocks).length
|
||||
|
||||
// Calculate position based on existing blocks and current action index
|
||||
const row = Math.floor((blocksCount + index) / 5) // 5 blocks per row
|
||||
const col = (blocksCount + index) % 5
|
||||
|
||||
return {
|
||||
x: startX + col * xSpacing,
|
||||
y: startY + row * ySpacing,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user