Better formatting

This commit is contained in:
Siddharth Ganesan
2025-07-08 16:42:03 -07:00
parent 17513d77ea
commit 70a51006f6
5 changed files with 370 additions and 302 deletions

View File

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

View File

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

View File

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

View File

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

View File

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