mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Better formatting
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
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 { 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')
|
||||
|
||||
@@ -74,10 +74,17 @@ async function searchDocs(queryEmbedding: number[], topK: number) {
|
||||
/**
|
||||
* Generate response using LLM with retrieved context
|
||||
*/
|
||||
async function generateResponse(query: string, chunks: any[], provider?: string, model?: string, stream: boolean = false): Promise<string | ReadableStream> {
|
||||
async function generateResponse(
|
||||
query: string,
|
||||
chunks: any[],
|
||||
provider?: string,
|
||||
model?: string,
|
||||
stream = 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)
|
||||
const selectedModel =
|
||||
model || DOCS_RAG_CONFIG.defaultModel || getProviderDefaultModel(selectedProvider)
|
||||
|
||||
// Get API key for the selected provider
|
||||
let apiKey: string
|
||||
@@ -103,11 +110,19 @@ async function generateResponse(query: string, chunks: any[], provider?: string,
|
||||
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 || '')
|
||||
|
||||
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}
|
||||
@@ -117,15 +132,20 @@ Content: ${chunkText}`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Guidelines:
|
||||
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
|
||||
- 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
|
||||
@@ -162,33 +182,33 @@ ${context}`
|
||||
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')
|
||||
}
|
||||
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')
|
||||
}
|
||||
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'}`)
|
||||
throw new Error(
|
||||
`Failed to generate response using ${selectedProvider}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,14 +218,17 @@ ${context}`
|
||||
*/
|
||||
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),
|
||||
model:
|
||||
model ||
|
||||
DOCS_RAG_CONFIG.defaultModel ||
|
||||
getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider),
|
||||
topK,
|
||||
})
|
||||
|
||||
@@ -214,10 +237,7 @@ export async function POST(req: NextRequest) {
|
||||
const queryEmbedding = await generateSearchEmbedding(query)
|
||||
|
||||
if (queryEmbedding.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate query embedding' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to generate query embedding' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Step 2: Search for relevant docs chunks
|
||||
@@ -227,14 +247,18 @@ export async function POST(req: NextRequest) {
|
||||
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.",
|
||||
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),
|
||||
model:
|
||||
model ||
|
||||
DOCS_RAG_CONFIG.defaultModel ||
|
||||
getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -254,16 +278,16 @@ export async function POST(req: NextRequest) {
|
||||
// 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',
|
||||
@@ -274,27 +298,30 @@ export async function POST(req: NextRequest) {
|
||||
query,
|
||||
topSimilarity: sources[0]?.similarity,
|
||||
provider: provider || DOCS_RAG_CONFIG.defaultProvider,
|
||||
model: model || DOCS_RAG_CONFIG.defaultModel || getProviderDefaultModel(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`))
|
||||
|
||||
// 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) {
|
||||
@@ -313,7 +340,7 @@ export async function POST(req: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -331,10 +358,12 @@ export async function POST(req: NextRequest) {
|
||||
query,
|
||||
topSimilarity: sources[0]?.similarity,
|
||||
provider: provider || DOCS_RAG_CONFIG.defaultProvider,
|
||||
model: model || DOCS_RAG_CONFIG.defaultModel || getProviderDefaultModel(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(
|
||||
@@ -344,9 +373,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] RAG error:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useMemo } from 'react'
|
||||
import { Send, Bot, User, ExternalLink, Loader2, Expand, X } from 'lucide-react'
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { Bot, Expand, Loader2, Send, User, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
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')
|
||||
|
||||
@@ -43,183 +42,199 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Expose clear function to parent
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearMessages: () => {
|
||||
setMessages([])
|
||||
logger.info('Copilot messages cleared')
|
||||
}
|
||||
}), [])
|
||||
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]')
|
||||
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 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()}`)
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
// 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[] = []
|
||||
const streamingMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
}
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader')
|
||||
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()}`)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
// 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[] = []
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const lines = chunk.split('\n')
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader')
|
||||
}
|
||||
|
||||
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')
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
|
||||
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 = {
|
||||
const errorMessage: Message = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content: data.response || 'Sorry, I could not generate a response.',
|
||||
content:
|
||||
'Sorry, I encountered an error while searching the documentation. Please try again.',
|
||||
timestamp: new Date(),
|
||||
sources: data.sources || [],
|
||||
isStreaming: false,
|
||||
}
|
||||
|
||||
setMessages(prev => prev.slice(0, -1).concat(assistantMessage))
|
||||
setMessages((prev) => prev.slice(0, -1).concat(errorMessage))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
} 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])
|
||||
},
|
||||
[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
|
||||
// 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
|
||||
// Replace {cite:1}, {cite:2}, etc. with clickable citation icons
|
||||
processedContent = processedContent.replace(/\{cite:(\d+)\}/g, (match, num) => {
|
||||
const sourceIndex = Number.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 `<a href="${source.link}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center ml-1 text-primary hover:text-primary/80 transition-colors text-sm" title="${source.title}">↗</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>')
|
||||
.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>')
|
||||
.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
|
||||
@@ -234,30 +249,37 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
// 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>')) {
|
||||
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 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 className='flex-1'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>Copilot</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{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 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>
|
||||
@@ -265,205 +287,220 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
}
|
||||
|
||||
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'
|
||||
}`}>
|
||||
<div key={message.id} className='group flex gap-3 p-4 hover:bg-muted/30'>
|
||||
<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" />
|
||||
<User className='h-4 w-4 text-muted-foreground' />
|
||||
) : (
|
||||
<Bot className="h-4 w-4 text-primary-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">
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>
|
||||
{message.role === 'user' ? 'You' : 'Copilot'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{formatTimestamp(message.timestamp)}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{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 className='flex items-center gap-1'>
|
||||
<Loader2 className='h-3 w-3 animate-spin text-primary' />
|
||||
<span className='text-primary text-xs'>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"
|
||||
<div className='prose prose-sm dark:prose-invert max-w-none'>
|
||||
<div
|
||||
className='text-foreground text-sm leading-relaxed'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderContentWithCitations(message.content, message.sources)
|
||||
__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" />
|
||||
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-primary' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
return (
|
||||
<>
|
||||
{/* Main Panel Content */}
|
||||
<div className="flex h-full flex-col">
|
||||
<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 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>
|
||||
<h3 className='font-medium text-sm'>Documentation Copilot</h3>
|
||||
<p className='text-muted-foreground text-xs'>Ask questions about Sim Studio</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="h-8 w-8"
|
||||
title="Expand"
|
||||
className='h-8 w-8'
|
||||
title='Expand'
|
||||
>
|
||||
<Expand className="h-4 w-4" />
|
||||
<Expand className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||||
<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">
|
||||
<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-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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{messages.map(renderMessage)}
|
||||
</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">
|
||||
<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..."
|
||||
placeholder='Ask about Sim Studio documentation...'
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
className='flex-1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
type='submit'
|
||||
size='icon'
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="h-10 w-10"
|
||||
className='h-10 w-10'
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
<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">
|
||||
<DialogContent className='h-[80vh] w-full max-w-4xl 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 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>
|
||||
<h3 className='font-medium text-sm'>Documentation Copilot</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Ask questions about Sim Studio
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="h-8 w-8"
|
||||
title="Close"
|
||||
className='h-8 w-8'
|
||||
title='Close'
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1">
|
||||
<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.
|
||||
<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-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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{messages.map(renderMessage)}
|
||||
</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">
|
||||
<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..."
|
||||
placeholder='Ask about Sim Studio documentation...'
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
className='flex-1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
type='submit'
|
||||
size='icon'
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="h-10 w-10"
|
||||
className='h-10 w-10'
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
<Send className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -476,4 +513,4 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
)
|
||||
})
|
||||
|
||||
Copilot.displayName = 'Copilot'
|
||||
Copilot.displayName = 'Copilot'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Expand, PanelRight } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
@@ -10,8 +10,8 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
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'
|
||||
import { Variables } from './components/variables/variables'
|
||||
|
||||
export function Panel() {
|
||||
const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width)
|
||||
|
||||
@@ -29,7 +29,8 @@ export class DocsChunker {
|
||||
})
|
||||
// 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')
|
||||
this.baseUrl =
|
||||
options.baseUrl ?? (isDev ? 'http://localhost:3001' : 'https://docs.simstudio.ai')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,11 @@ async function processDocsEmbeddings(options: ProcessingOptions = {}) {
|
||||
clearExisting: options.clearExisting ?? false,
|
||||
docsPath: options.docsPath ?? path.join(process.cwd(), '../../apps/docs/content/docs'),
|
||||
// Use localhost docs in development, production docs otherwise
|
||||
baseUrl: options.baseUrl ?? (process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://docs.simstudio.ai'),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user