mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
Initial chatbot ui
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/db'
|
||||
import { docsEmbeddings } from '@/db/schema'
|
||||
import { generateEmbeddings } from '@/app/api/knowledge/utils'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { env } from '@/lib/env'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getProviderDefaultModel } from '@/providers/models'
|
||||
import { getRotatingApiKey } from '@/lib/utils'
|
||||
|
||||
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),
|
||||
provider: z.string().optional(), // Allow override of provider per request
|
||||
model: z.string().optional(), // Allow override of model per request
|
||||
stream: z.boolean().optional().default(false), // Enable streaming responses
|
||||
})
|
||||
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate response using LLM with retrieved context
|
||||
*/
|
||||
async function generateResponse(query: string, chunks: any[], provider?: string, model?: string, stream: boolean = false): 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)
|
||||
|
||||
// Get API key for the selected provider
|
||||
let apiKey: string
|
||||
try {
|
||||
if (selectedProvider === 'openai' || selectedProvider === 'azure-openai') {
|
||||
apiKey = getRotatingApiKey('openai')
|
||||
} else if (selectedProvider === 'anthropic') {
|
||||
apiKey = getRotatingApiKey('anthropic')
|
||||
} 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}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get API key for provider ${selectedProvider}:`, error)
|
||||
throw new Error(`API key not configured for provider: ${selectedProvider}`)
|
||||
}
|
||||
|
||||
// Format chunks as context with numbered sources
|
||||
const context = chunks
|
||||
.map((chunk, index) => {
|
||||
// Ensure all chunk properties are strings to avoid object serialization
|
||||
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')
|
||||
|
||||
const systemPrompt = `You are a helpful assistant that answers questions about Sim Studio documentation.
|
||||
|
||||
IMPORTANT: Use inline citations throughout your response. When referencing information from the sources, include the citation number in square brackets like [1], [2], etc.
|
||||
|
||||
Guidelines:
|
||||
- Answer the user's question accurately using the provided documentation
|
||||
- Include inline citations [1], [2], etc. when referencing specific information
|
||||
- Use multiple citations for comprehensive answers
|
||||
- Format your response in clean, readable markdown
|
||||
- Use bullet points, code blocks, and headers where appropriate
|
||||
- If information spans multiple sources, cite all relevant ones
|
||||
- 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 [${chunks.length}] in the context below.`
|
||||
|
||||
const userPrompt = `Question: ${query}
|
||||
|
||||
Documentation Context:
|
||||
${context}`
|
||||
|
||||
try {
|
||||
logger.info(`Generating response using provider: ${selectedProvider}, model: ${selectedModel}`)
|
||||
|
||||
const providerRequest = {
|
||||
model: selectedModel,
|
||||
systemPrompt,
|
||||
context: userPrompt,
|
||||
temperature: DOCS_RAG_CONFIG.temperature,
|
||||
maxTokens: DOCS_RAG_CONFIG.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)
|
||||
|
||||
// Handle different response types
|
||||
if (response instanceof ReadableStream) {
|
||||
if (stream) {
|
||||
return response // Return the stream directly for streaming requests
|
||||
} else {
|
||||
throw new Error('Unexpected streaming response when non-streaming was requested')
|
||||
}
|
||||
}
|
||||
|
||||
if ('stream' in response && 'execution' in response) {
|
||||
// Handle StreamingExecution for providers like Anthropic
|
||||
if (stream) {
|
||||
return response.stream // Return the stream from StreamingExecution
|
||||
} else {
|
||||
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 cleanedContent
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate LLM response:', error)
|
||||
throw new Error(`Failed to generate response using ${selectedProvider}: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/docs/ask
|
||||
* Ask questions about Sim Studio documentation using RAG
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { query, topK, provider, model, stream } = DocsQuerySchema.parse(body)
|
||||
|
||||
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),
|
||||
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 your question. Please try rephrasing your query or check if you're asking about a feature that exists in Sim Studio.",
|
||||
sources: [],
|
||||
metadata: {
|
||||
requestId,
|
||||
chunksFound: 0,
|
||||
query,
|
||||
provider: provider || DOCS_RAG_CONFIG.defaultProvider,
|
||||
model: model || DOCS_RAG_CONFIG.defaultModel || getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Step 3: Generate response using LLM
|
||||
logger.info(`[${requestId}] Generating LLM response with ${chunks.length} chunks...`)
|
||||
const response = await generateResponse(query, chunks, provider, model, stream)
|
||||
|
||||
// Step 4: Format sources for response
|
||||
const sources = chunks.map((chunk) => ({
|
||||
title: chunk.headerText,
|
||||
document: chunk.sourceDocument,
|
||||
link: chunk.sourceLink,
|
||||
similarity: Math.round(chunk.similarity * 100) / 100,
|
||||
}))
|
||||
|
||||
// Handle streaming response
|
||||
if (response instanceof ReadableStream) {
|
||||
logger.info(`[${requestId}] Returning streaming response`)
|
||||
|
||||
// Create a new stream that includes metadata
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.getReader()
|
||||
|
||||
// Send initial metadata
|
||||
const metadata = {
|
||||
type: 'metadata',
|
||||
sources,
|
||||
metadata: {
|
||||
requestId,
|
||||
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),
|
||||
},
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`))
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
// Forward the chunk with content type
|
||||
const chunkText = decoder.decode(value)
|
||||
// Clean up any object serialization artifacts in streaming content
|
||||
const cleanedChunk = chunkText.replace(/\[object Object\],?/g, '')
|
||||
const contentChunk = {
|
||||
type: 'content',
|
||||
content: cleanedChunk,
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`))
|
||||
}
|
||||
|
||||
// Send end marker
|
||||
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',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] RAG response generated successfully`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response,
|
||||
sources,
|
||||
metadata: {
|
||||
requestId,
|
||||
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),
|
||||
},
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] RAG error:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useMemo } from 'react'
|
||||
import { Send, Bot, User, ExternalLink, Loader2, Expand, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
const logger = createLogger('Copilot')
|
||||
|
||||
interface CopilotProps {
|
||||
panelWidth: number
|
||||
}
|
||||
|
||||
interface CopilotRef {
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
sources?: {
|
||||
title: string
|
||||
document: string
|
||||
link: string
|
||||
similarity: number
|
||||
}[]
|
||||
isLoading?: boolean
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref) => {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Expose clear function to parent
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearMessages: () => {
|
||||
setMessages([])
|
||||
logger.info('Copilot messages cleared')
|
||||
}
|
||||
}), [])
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]')
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || isLoading) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
const streamingMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage, streamingMessage])
|
||||
const query = input.trim()
|
||||
setInput('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
logger.info('Sending docs RAG query:', { query })
|
||||
|
||||
const response = await fetch('/api/docs/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
topK: 5,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
if (response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let sources: any[] = []
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader')
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) 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') {
|
||||
sources = data.sources || []
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
|
||||
// Update the streaming message with accumulated content
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === streamingMessage.id
|
||||
? { ...msg, content: accumulatedContent, sources }
|
||||
: msg
|
||||
))
|
||||
} else if (data.type === 'done') {
|
||||
// Finish streaming
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === streamingMessage.id
|
||||
? { ...msg, isStreaming: false, sources }
|
||||
: msg
|
||||
))
|
||||
} 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 docs RAG response:', {
|
||||
contentLength: accumulatedContent.length,
|
||||
sourcesCount: sources.length,
|
||||
})
|
||||
|
||||
} else {
|
||||
// Fallback to non-streaming response
|
||||
const data = await response.json()
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content: data.response || 'Sorry, I could not generate a response.',
|
||||
timestamp: new Date(),
|
||||
sources: data.sources || [],
|
||||
isStreaming: false,
|
||||
}
|
||||
|
||||
setMessages(prev => prev.slice(0, -1).concat(assistantMessage))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Docs RAG error:', error)
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error while searching the documentation. Please try again.',
|
||||
timestamp: new Date(),
|
||||
isStreaming: false,
|
||||
}
|
||||
|
||||
setMessages(prev => prev.slice(0, -1).concat(errorMessage))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [input, isLoading])
|
||||
|
||||
const formatTimestamp = (date: Date) => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Function to render content with inline hyperlinked citations and basic markdown
|
||||
const renderContentWithCitations = (content: string, sources: Message['sources'] = []) => {
|
||||
if (!content) return content
|
||||
|
||||
let processedContent = content
|
||||
|
||||
// Replace [1], [2], etc. with hyperlinked citations
|
||||
processedContent = processedContent.replace(/\[(\d+)\]/g, (match, num) => {
|
||||
const sourceIndex = parseInt(num) - 1
|
||||
const source = sources[sourceIndex]
|
||||
|
||||
if (source) {
|
||||
return `<a href="${source.link}" target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary/80 underline decoration-2 underline-offset-2 font-medium">${match}</a>`
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
|
||||
// Basic markdown processing for better formatting
|
||||
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
|
||||
.replace(/\n\n/g, '</p><p class="my-2">')
|
||||
.replace(/\n/g, '<br>')
|
||||
|
||||
// Wrap in paragraph tags if not already wrapped
|
||||
if (!processedContent.includes('<p>') && !processedContent.includes('<h1>') && !processedContent.includes('<h2>') && !processedContent.includes('<h3>')) {
|
||||
processedContent = `<p class="my-2">${processedContent}</p>`
|
||||
}
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
const renderMessage = (message: Message) => {
|
||||
if (message.isStreaming && !message.content) {
|
||||
return (
|
||||
<div key={message.id} className="flex gap-3 p-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary">
|
||||
<Bot className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm">Copilot</span>
|
||||
<span className="text-xs text-muted-foreground">{formatTimestamp(message.timestamp)}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={message.id} className="flex gap-3 p-4 hover:bg-muted/30 group">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full ${
|
||||
message.role === 'user' ? 'bg-muted' : 'bg-primary'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-sm">
|
||||
{message.role === 'user' ? 'You' : 'Copilot'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{formatTimestamp(message.timestamp)}</span>
|
||||
{message.isStreaming && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
||||
<span className="text-xs text-primary">Responding...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enhanced content rendering with inline citations */}
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<div
|
||||
className="text-sm text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderContentWithCitations(message.content, message.sources)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{message.isStreaming && message.content && (
|
||||
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Panel Content */}
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">Documentation Copilot</h3>
|
||||
<p className="text-xs text-muted-foreground">Ask questions about Sim Studio</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="h-8 w-8"
|
||||
title="Expand"
|
||||
>
|
||||
<Expand className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Bot className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="font-medium text-sm mb-2">Welcome to Documentation Copilot</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4 max-w-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-xs text-muted-foreground">Try asking:</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs bg-muted/50 rounded px-2 py-1">"How do I create a workflow?"</div>
|
||||
<div className="text-xs bg-muted/50 rounded px-2 py-1">"What tools are available?"</div>
|
||||
<div className="text-xs bg-muted/50 rounded px-2 py-1">"How do I deploy my workflow?"</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{messages.map(renderMessage)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<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}
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
{isFullscreen && (
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent className="max-w-4xl w-full h-[80vh] p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">Documentation Copilot</h3>
|
||||
<p className="text-xs text-muted-foreground">Ask questions about Sim Studio</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="h-8 w-8"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Bot className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="font-medium text-sm mb-2">Welcome to Documentation Copilot</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4 max-w-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-xs text-muted-foreground">Try asking:</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs bg-muted/50 rounded px-2 py-1">"How do I create a workflow?"</div>
|
||||
<div className="text-xs bg-muted/50 rounded px-2 py-1">"What tools are available?"</div>
|
||||
<div className="text-xs bg-muted/50 rounded px-2 py-1">"How do I deploy my workflow?"</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{messages.map(renderMessage)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t p-4">
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask about Sim Studio documentation..."
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
Copilot.displayName = 'Copilot'
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Expand, PanelRight } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
@@ -11,12 +11,14 @@ import { Chat } from './components/chat/chat'
|
||||
import { ChatModal } from './components/chat/components/chat-modal/chat-modal'
|
||||
import { Console } from './components/console/console'
|
||||
import { Variables } from './components/variables/variables'
|
||||
import { Copilot } from './components/copilot/copilot'
|
||||
|
||||
export function Panel() {
|
||||
const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [chatMessage, setChatMessage] = useState<string>('')
|
||||
const [isChatModalOpen, setIsChatModalOpen] = useState(false)
|
||||
const copilotRef = useRef<{ clearMessages: () => void }>(null)
|
||||
|
||||
const isOpen = usePanelStore((state) => state.isOpen)
|
||||
const togglePanel = usePanelStore((state) => state.togglePanel)
|
||||
@@ -116,15 +118,29 @@ export function Panel() {
|
||||
>
|
||||
Variables
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('copilot')}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
activeTab === 'copilot'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Copilot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(activeTab === 'console' || activeTab === 'chat') && (
|
||||
{(activeTab === 'console' || activeTab === 'chat' || activeTab === 'copilot') && (
|
||||
<button
|
||||
onClick={() =>
|
||||
activeTab === 'console'
|
||||
? clearConsole(activeWorkflowId)
|
||||
: clearChat(activeWorkflowId)
|
||||
}
|
||||
onClick={() => {
|
||||
if (activeTab === 'console') {
|
||||
clearConsole(activeWorkflowId)
|
||||
} else if (activeTab === 'chat') {
|
||||
clearChat(activeWorkflowId)
|
||||
} else if (activeTab === 'copilot') {
|
||||
copilotRef.current?.clearMessages()
|
||||
}
|
||||
}}
|
||||
className='rounded-md px-3 py-1 text-muted-foreground text-sm transition-colors hover:bg-accent/50 hover:text-foreground'
|
||||
>
|
||||
Clear
|
||||
@@ -138,6 +154,8 @@ export function Panel() {
|
||||
<Chat panelWidth={width} chatMessage={chatMessage} setChatMessage={setChatMessage} />
|
||||
) : activeTab === 'console' ? (
|
||||
<Console panelWidth={width} />
|
||||
) : activeTab === 'copilot' ? (
|
||||
<Copilot ref={copilotRef} panelWidth={width} />
|
||||
) : (
|
||||
<Variables panelWidth={width} />
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,9 @@ export class DocsChunker {
|
||||
minChunkSize: options.minChunkSize ?? 100,
|
||||
overlap: options.overlap ?? 50,
|
||||
})
|
||||
this.baseUrl = options.baseUrl ?? 'https://docs.simstudio.ai'
|
||||
// Use localhost docs in development, production docs otherwise
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
this.baseUrl = options.baseUrl ?? (isDev ? 'http://localhost:3001' : 'https://docs.simstudio.ai')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,7 +37,8 @@ async function processDocsEmbeddings(options: ProcessingOptions = {}) {
|
||||
const config = {
|
||||
clearExisting: options.clearExisting ?? false,
|
||||
docsPath: options.docsPath ?? path.join(process.cwd(), '../../apps/docs/content/docs'),
|
||||
baseUrl: options.baseUrl ?? 'https://docs.simstudio.ai',
|
||||
// Use localhost docs in development, production docs otherwise
|
||||
baseUrl: options.baseUrl ?? (process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://docs.simstudio.ai'),
|
||||
chunkSize: options.chunkSize ?? 300, // Max 300 tokens per chunk
|
||||
minChunkSize: options.minChunkSize ?? 100,
|
||||
overlap: options.overlap ?? 50,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type PanelTab = 'console' | 'variables' | 'chat'
|
||||
export type PanelTab = 'console' | 'variables' | 'chat' | 'copilot'
|
||||
|
||||
export interface PanelStore {
|
||||
isOpen: boolean
|
||||
|
||||
Reference in New Issue
Block a user