mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
Tool call version
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getRotatingApiKey } from '@/lib/utils'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { copilotChats } from '@/db/schema'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import type { Message } from '@/providers/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const logger = createLogger('CopilotChat')
|
||||
|
||||
// Configuration for copilot chat
|
||||
const COPILOT_CONFIG = {
|
||||
defaultProvider: 'anthropic',
|
||||
defaultModel: 'claude-3-7-sonnet-latest',
|
||||
temperature: 0.1,
|
||||
maxTokens: 4000, // Increased for more comprehensive documentation responses
|
||||
} as const
|
||||
|
||||
const CopilotChatSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate a chat title using LLM based on the first user message
|
||||
*/
|
||||
async function generateChatTitle(userMessage: string): Promise<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
|
||||
}> {
|
||||
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, but use it SELECTIVELY.
|
||||
|
||||
WHEN TO SEARCH DOCUMENTATION:
|
||||
- User asks "How do I create a workflow?"
|
||||
- User asks about specific tools or blocks
|
||||
- User needs help with Sim Studio features
|
||||
- User has technical questions about the platform
|
||||
|
||||
WHEN NOT TO SEARCH DOCUMENTATION:
|
||||
- Simple greetings like "hi", "hello", "hey"
|
||||
- General conversation like "how are you?"
|
||||
- Thank you messages
|
||||
- General programming questions unrelated to Sim Studio
|
||||
- Small talk or casual conversation
|
||||
|
||||
Guidelines:
|
||||
- Be conversational and helpful
|
||||
- For greetings and casual conversation, respond directly without searching
|
||||
- Only use docs_search_internal when the user specifically needs information about Sim Studio features
|
||||
- When you do search, synthesize the information and provide clear, actionable answers
|
||||
- Be friendly and natural in your responses
|
||||
|
||||
CITATION INSTRUCTIONS:
|
||||
When you search documentation and reference information from the sources, use inline citations strategically and sparingly:
|
||||
- Use citation markers like {cite:1}, {cite:2}, etc. to reference specific sources
|
||||
- Cite each source only ONCE at the specific header or topic that relates to that source
|
||||
- Place citations directly after the header or concept that the source specifically addresses
|
||||
- If multiple sources support the same topic, cite them together like {cite:1}{cite:2}{cite:3}
|
||||
- Do NOT repeatedly cite the same source throughout your response
|
||||
- Only cite sources that you actually reference in your answer
|
||||
|
||||
MAKE SURE YOU FULLY ANSWER THE USER'S QUESTION.
|
||||
`
|
||||
// Define the documentation search tool for the LLM
|
||||
const tools = [
|
||||
{
|
||||
id: 'docs_search_internal',
|
||||
name: 'Search Documentation',
|
||||
description: 'Search Sim Studio documentation for information about features, tools, workflows, and functionality',
|
||||
params: {},
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The search query to find relevant documentation',
|
||||
},
|
||||
topK: {
|
||||
type: 'number',
|
||||
description: 'Number of results to return (default: 5, max: 10)',
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
// For streaming, we always make a non-streaming request first to handle tool calls
|
||||
// Then we stream the final response if no tool calls were needed
|
||||
const response = await executeProviderRequest('anthropic', {
|
||||
model: COPILOT_CONFIG.defaultModel,
|
||||
systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
temperature: COPILOT_CONFIG.temperature,
|
||||
maxTokens: COPILOT_CONFIG.maxTokens,
|
||||
apiKey,
|
||||
stream: false, // Always start with non-streaming to handle tool calls
|
||||
})
|
||||
|
||||
|
||||
|
||||
// If this is a streaming request and we got a regular response,
|
||||
// we need to create a streaming response from the content
|
||||
if (stream && typeof response === 'object' && 'content' in response) {
|
||||
const content = response.content || 'Sorry, I could not generate a response.'
|
||||
|
||||
// Extract citations from the provider response for later use
|
||||
const responseCitations = extractCitationsFromResponse(response)
|
||||
|
||||
// Create a ReadableStream that emits the content in character chunks
|
||||
const streamResponse = new ReadableStream({
|
||||
start(controller) {
|
||||
// Use character-based streaming for more reliable transmission
|
||||
const chunkSize = 8 // Stream 8 characters at a time for smooth experience
|
||||
let index = 0
|
||||
|
||||
const pushNext = () => {
|
||||
if (index < content.length) {
|
||||
const chunk = content.slice(index, index + chunkSize)
|
||||
controller.enqueue(new TextEncoder().encode(chunk))
|
||||
index += chunkSize
|
||||
|
||||
// Add a small delay to simulate streaming
|
||||
setTimeout(pushNext, 25)
|
||||
} else {
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
|
||||
pushNext()
|
||||
}
|
||||
})
|
||||
|
||||
// Store citations for later use in the main streaming handler
|
||||
;(streamResponse as any)._citations = responseCitations
|
||||
|
||||
return streamResponse
|
||||
}
|
||||
|
||||
// Handle regular response
|
||||
if (typeof response === 'object' && 'content' in response) {
|
||||
return response.content || 'Sorry, I could not generate a response.'
|
||||
}
|
||||
|
||||
return 'Sorry, I could not generate a response.'
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate chat response:', error)
|
||||
throw new Error(`Failed to generate response: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/copilot/chat
|
||||
* Chat with the copilot using LLM with tool calling
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { message, chatId, workflowId, createNewChat, stream } = CopilotChatSchema.parse(body)
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
logger.info(`[${requestId}] Copilot chat message: "${message}"`, {
|
||||
chatId,
|
||||
workflowId,
|
||||
createNewChat,
|
||||
stream,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
|
||||
if (chatId && session?.user?.id) {
|
||||
// Load existing chat
|
||||
const [existingChat] = await db
|
||||
.select()
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingChat) {
|
||||
currentChat = existingChat
|
||||
conversationHistory = Array.isArray(existingChat.messages) ? existingChat.messages : []
|
||||
}
|
||||
} else if (createNewChat && workflowId && session?.user?.id) {
|
||||
// Create new chat
|
||||
const [newChat] = await db
|
||||
.insert(copilotChats)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
workflowId,
|
||||
title: null,
|
||||
model: COPILOT_CONFIG.defaultModel,
|
||||
messages: [],
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (newChat) {
|
||||
currentChat = newChat
|
||||
conversationHistory = []
|
||||
}
|
||||
}
|
||||
|
||||
// Generate chat response
|
||||
const response = await generateChatResponse(message, conversationHistory, stream, requestId)
|
||||
|
||||
// Handle streaming response
|
||||
if (response instanceof ReadableStream) {
|
||||
logger.info(`[${requestId}] Returning streaming response`)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
// Extract citations from the stream object if available
|
||||
const citations = (response as any)._citations || []
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.getReader()
|
||||
let accumulatedResponse = ''
|
||||
|
||||
// Send initial metadata
|
||||
const metadata = {
|
||||
type: 'metadata',
|
||||
chatId: currentChat?.id,
|
||||
citations: citations,
|
||||
metadata: {
|
||||
requestId,
|
||||
message,
|
||||
},
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`))
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunkText = new TextDecoder().decode(value)
|
||||
accumulatedResponse += chunkText
|
||||
|
||||
const contentChunk = {
|
||||
type: 'content',
|
||||
content: chunkText,
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`))
|
||||
}
|
||||
|
||||
// Save conversation to database after streaming completes
|
||||
if (currentChat && session?.user?.id) {
|
||||
const userMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: accumulatedResponse,
|
||||
timestamp: new Date().toISOString(),
|
||||
citations: citations.length > 0 ? citations : undefined,
|
||||
}
|
||||
|
||||
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
|
||||
|
||||
// Generate title if this is the first message
|
||||
let updatedTitle = currentChat.title
|
||||
if (!updatedTitle && conversationHistory.length === 0) {
|
||||
updatedTitle = await generateChatTitle(message)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title: updatedTitle,
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, currentChat.id))
|
||||
|
||||
logger.info(`[${requestId}] Updated chat ${currentChat.id} with new messages`)
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: {"type":"done"}\n\n`))
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Streaming error:`, error)
|
||||
const errorChunk = {
|
||||
type: 'error',
|
||||
error: 'Streaming failed',
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`))
|
||||
} finally {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Save conversation to database for non-streaming response
|
||||
if (currentChat && session?.user?.id) {
|
||||
const userMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Extract citations from response if available
|
||||
const citations = typeof response === 'object' && 'citations' in response ? response.citations :
|
||||
typeof response === 'object' && 'toolResults' in response ? extractCitationsFromResponse(response) : []
|
||||
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: typeof response === 'string' ? response :
|
||||
'content' in response ? response.content : '[Error generating response]',
|
||||
timestamp: new Date().toISOString(),
|
||||
citations: citations.length > 0 ? citations : undefined,
|
||||
}
|
||||
|
||||
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
|
||||
|
||||
// Generate title if this is the first message
|
||||
let updatedTitle = currentChat.title
|
||||
if (!updatedTitle && conversationHistory.length === 0) {
|
||||
updatedTitle = await generateChatTitle(message)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title: updatedTitle,
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, currentChat.id))
|
||||
|
||||
logger.info(`[${requestId}] Updated chat ${currentChat.id} with new messages`)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Chat response generated successfully`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response: typeof response === 'string' ? response :
|
||||
'content' in response ? response.content : '[Error generating response]',
|
||||
chatId: currentChat?.id,
|
||||
metadata: {
|
||||
requestId,
|
||||
message,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Copilot chat error:`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { generateEmbeddings } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { docsEmbeddings } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('DocsSearch')
|
||||
|
||||
const DocsSearchSchema = z.object({
|
||||
query: z.string().min(1, 'Query is required'),
|
||||
topK: z.number().min(1).max(10).default(5),
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate embedding for search query
|
||||
*/
|
||||
async function generateSearchEmbedding(query: string): Promise<number[]> {
|
||||
try {
|
||||
const embeddings = await generateEmbeddings([query])
|
||||
return embeddings[0] || []
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate search embedding:', error)
|
||||
throw new Error('Failed to generate search embedding')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search docs embeddings using vector similarity
|
||||
*/
|
||||
async function searchDocs(queryEmbedding: number[], topK: number) {
|
||||
try {
|
||||
const results = await db
|
||||
.select({
|
||||
chunkId: docsEmbeddings.chunkId,
|
||||
chunkText: docsEmbeddings.chunkText,
|
||||
sourceDocument: docsEmbeddings.sourceDocument,
|
||||
sourceLink: docsEmbeddings.sourceLink,
|
||||
headerText: docsEmbeddings.headerText,
|
||||
headerLevel: docsEmbeddings.headerLevel,
|
||||
similarity: sql<number>`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`,
|
||||
})
|
||||
.from(docsEmbeddings)
|
||||
.orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`)
|
||||
.limit(topK)
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
logger.error('Failed to search docs:', error)
|
||||
throw new Error('Failed to search docs')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/docs/search
|
||||
* Search Sim Studio documentation using vector similarity
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { query, topK } = DocsSearchSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] 🔍 DOCS SEARCH TOOL CALLED - Query: "${query}"`, { topK })
|
||||
|
||||
// Step 1: Generate embedding for the query
|
||||
logger.info(`[${requestId}] Generating query embedding...`)
|
||||
const queryEmbedding = await generateSearchEmbedding(query)
|
||||
|
||||
if (queryEmbedding.length === 0) {
|
||||
return NextResponse.json({ error: 'Failed to generate query embedding' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Step 2: Search for relevant docs chunks
|
||||
logger.info(`[${requestId}] Searching docs for top ${topK} chunks...`)
|
||||
const chunks = await searchDocs(queryEmbedding, topK)
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response: "I couldn't find any relevant documentation for that query.",
|
||||
sources: [],
|
||||
metadata: {
|
||||
requestId,
|
||||
chunksFound: 0,
|
||||
query,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Step 3: Format the response with context and sources
|
||||
const context = chunks
|
||||
.map((chunk, index) => {
|
||||
const headerText = typeof chunk.headerText === 'string' ? chunk.headerText : String(chunk.headerText || 'Untitled Section')
|
||||
const sourceDocument = typeof chunk.sourceDocument === 'string' ? chunk.sourceDocument : String(chunk.sourceDocument || 'Unknown Document')
|
||||
const sourceLink = typeof chunk.sourceLink === 'string' ? chunk.sourceLink : String(chunk.sourceLink || '#')
|
||||
const chunkText = typeof chunk.chunkText === 'string' ? chunk.chunkText : String(chunk.chunkText || '')
|
||||
|
||||
return `[${index + 1}] ${headerText}
|
||||
Document: ${sourceDocument}
|
||||
URL: ${sourceLink}
|
||||
Content: ${chunkText}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Step 4: Format sources for response
|
||||
const sources = chunks.map((chunk, index) => ({
|
||||
id: index + 1,
|
||||
title: chunk.headerText,
|
||||
document: chunk.sourceDocument,
|
||||
link: chunk.sourceLink,
|
||||
similarity: Math.round(chunk.similarity * 100) / 100,
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Found ${chunks.length} relevant chunks`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response: context,
|
||||
sources,
|
||||
metadata: {
|
||||
requestId,
|
||||
chunksFound: chunks.length,
|
||||
query,
|
||||
topSimilarity: sources[0]?.similarity,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Docs search error:`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
+14
-20
@@ -197,8 +197,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
logger.info('Sending docs RAG query:', { query, chatId: currentChat?.id })
|
||||
|
||||
const result = await sendStreamingMessage({
|
||||
query,
|
||||
topK: 5,
|
||||
message: query,
|
||||
chatId: currentChat?.id,
|
||||
workflowId: activeWorkflowId,
|
||||
createNewChat: !currentChat,
|
||||
@@ -208,8 +207,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
const reader = result.stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let sources: any[] = []
|
||||
let newChatId: string | undefined
|
||||
let responseCitations: Array<{id: number, title: string, url: string}> = []
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
@@ -224,47 +223,43 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
const data = JSON.parse(line.slice(6))
|
||||
|
||||
if (data.type === 'metadata') {
|
||||
sources = data.sources || []
|
||||
// Get chatId from metadata (for both new and existing chats)
|
||||
if (data.chatId) {
|
||||
newChatId = data.chatId
|
||||
}
|
||||
// Get citations from metadata
|
||||
if (data.citations) {
|
||||
responseCitations = data.citations
|
||||
}
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
|
||||
// Update the streaming message with accumulated content
|
||||
// Update the streaming message with accumulated content and citations
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === streamingMessage.id
|
||||
? {
|
||||
...msg,
|
||||
content: accumulatedContent,
|
||||
citations: sources.map((source: any, index: number) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
})),
|
||||
citations: responseCitations.length > 0 ? responseCitations : undefined,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
)
|
||||
} else if (data.type === 'done') {
|
||||
// Finish streaming and reload chat if new chat was created
|
||||
// Final update to ensure citations are applied
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === streamingMessage.id
|
||||
? {
|
||||
...msg,
|
||||
citations: sources.map((source: any, index: number) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
})),
|
||||
content: accumulatedContent,
|
||||
citations: responseCitations.length > 0 ? responseCitations : undefined,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
// Update current chat state with the chatId from response
|
||||
if (newChatId && !currentChat) {
|
||||
// For new chats, create a temporary chat object and reload the full chat list
|
||||
@@ -289,9 +284,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Received docs RAG response:', {
|
||||
logger.info('Received copilot chat response:', {
|
||||
contentLength: accumulatedContent.length,
|
||||
sourcesCount: sources.length,
|
||||
})
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to send message')
|
||||
@@ -420,7 +414,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
{!message.content && (
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-sm'>Searching documentation...</span>
|
||||
<span className='text-sm'>Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -262,16 +262,21 @@ export async function sendMessage(request: DocsQueryRequest): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming message using the docs RAG API
|
||||
* Send a streaming message using the new copilot chat API
|
||||
*/
|
||||
export async function sendStreamingMessage(request: DocsQueryRequest): Promise<{
|
||||
export async function sendStreamingMessage(request: {
|
||||
message: string
|
||||
chatId?: string
|
||||
workflowId?: string
|
||||
createNewChat?: boolean
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
stream?: ReadableStream
|
||||
chatId?: string
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/docs/ask', {
|
||||
const response = await fetch('/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, stream: true }),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ToolConfig } from '../types'
|
||||
|
||||
export interface DocsSearchParams {
|
||||
query: string
|
||||
topK?: number
|
||||
}
|
||||
|
||||
export interface DocsSearchResponse {
|
||||
success: boolean
|
||||
output: {
|
||||
response: string
|
||||
sources: Array<{
|
||||
title: string
|
||||
document: string
|
||||
link: string
|
||||
similarity: number
|
||||
}>
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const docsSearchTool: ToolConfig<DocsSearchParams, DocsSearchResponse> = {
|
||||
id: 'docs_search_internal',
|
||||
name: 'Search Documentation',
|
||||
description: 'Search Sim Studio documentation using vector similarity search',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The search query to find relevant documentation',
|
||||
},
|
||||
topK: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Number of results to return (default: 5, max: 10)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/docs/search',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
query: params.query,
|
||||
topK: params.topK || 5,
|
||||
}),
|
||||
isInternalRoute: true,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to search documentation')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
response: data.response,
|
||||
sources: data.sources || [],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
return error instanceof Error ? error.message : 'An error occurred while searching documentation'
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools/store'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import { tools } from './registry'
|
||||
import { docsSearchTool } from './docs/search'
|
||||
import type { TableRow, ToolConfig, ToolResponse } from './types'
|
||||
|
||||
const logger = createLogger('ToolsUtils')
|
||||
@@ -267,8 +268,17 @@ export function createCustomToolRequestBody(
|
||||
}
|
||||
}
|
||||
|
||||
// Internal-only tools (not exposed to users in workflows)
|
||||
const internalTools: Record<string, ToolConfig> = {
|
||||
docs_search_internal: docsSearchTool,
|
||||
}
|
||||
|
||||
// Get a tool by its ID
|
||||
export function getTool(toolId: string): ToolConfig | undefined {
|
||||
// Check for internal tools first
|
||||
const internalTool = internalTools[toolId]
|
||||
if (internalTool) return internalTool
|
||||
|
||||
// Check for built-in tools
|
||||
const builtInTool = tools[toolId]
|
||||
if (builtInTool) return builtInTool
|
||||
@@ -302,6 +312,10 @@ export async function getToolAsync(
|
||||
toolId: string,
|
||||
workflowId?: string
|
||||
): Promise<ToolConfig | undefined> {
|
||||
// Check for internal tools first
|
||||
const internalTool = internalTools[toolId]
|
||||
if (internalTool) return internalTool
|
||||
|
||||
// Check for built-in tools
|
||||
const builtInTool = tools[toolId]
|
||||
if (builtInTool) return builtInTool
|
||||
|
||||
Reference in New Issue
Block a user