Big refactor

This commit is contained in:
Siddharth Ganesan
2025-07-08 19:14:51 -07:00
parent ee66c15ed9
commit d1fe209d29
15 changed files with 2306 additions and 1773 deletions

View File

@@ -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 })
}
}

View File

@@ -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
}
}

View File

@@ -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 })
}
}

View 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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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) {

View File

@@ -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>
)
}

View File

@@ -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}
/>
</>

View File

@@ -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',
}
}
}

View 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
}
}

View 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
}
}

View File

@@ -0,0 +1,8 @@
export { useCopilotStore } from './store'
export type {
CopilotMessage,
CopilotChat,
CopilotState,
CopilotActions,
CopilotStore,
} from './types'

View File

@@ -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' }
)

View File

@@ -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

View File

@@ -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,
}
}