From 70a51006f6d7ed48b69e50f8366b9aa3c5bd1dbc Mon Sep 17 00:00:00 2001
From: Siddharth Ganesan
Date: Tue, 8 Jul 2025 16:42:03 -0700
Subject: [PATCH] Better formatting
---
apps/sim/app/api/docs/ask/route.ts | 140 +++--
.../panel/components/copilot/copilot.tsx | 519 ++++++++++--------
.../w/[workflowId]/components/panel/panel.tsx | 4 +-
apps/sim/lib/documents/docs-chunker.ts | 3 +-
apps/sim/scripts/process-docs-embeddings.ts | 6 +-
5 files changed, 370 insertions(+), 302 deletions(-)
diff --git a/apps/sim/app/api/docs/ask/route.ts b/apps/sim/app/api/docs/ask/route.ts
index 489e355a8d..7768907e5a 100644
--- a/apps/sim/app/api/docs/ask/route.ts
+++ b/apps/sim/app/api/docs/ask/route.ts
@@ -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 {
+async function generateResponse(
+ query: string,
+ chunks: any[],
+ provider?: string,
+ model?: string,
+ stream = false
+): Promise {
// 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 })
}
-}
\ No newline at end of file
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
index 9281ff34d6..d263e65526 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
@@ -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(({ panelWidth }, ref
const inputRef = useRef(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 `${match}`
+ return `↗`
}
return match
})
-
+
// Basic markdown processing for better formatting
processedContent = processedContent
// Handle code blocks
- .replace(/```(\w+)?\n([\s\S]*?)```/g, '$2
')
+ .replace(
+ /```(\w+)?\n([\s\S]*?)```/g,
+ '$2
'
+ )
// Handle inline code
- .replace(/`([^`]+)`/g, '$1')
+ .replace(
+ /`([^`]+)`/g,
+ '$1'
+ )
// Handle bold text
.replace(/\*\*(.*?)\*\*/g, '$1')
// Handle italic text
@@ -234,30 +249,37 @@ export const Copilot = forwardRef(({ panelWidth }, ref
// Handle line breaks
.replace(/\n\n/g, '
')
.replace(/\n/g, '
')
-
+
// Wrap in paragraph tags if not already wrapped
- if (!processedContent.includes('
') && !processedContent.includes('
') && !processedContent.includes('') && !processedContent.includes('')) {
+ if (
+ !processedContent.includes('
') &&
+ !processedContent.includes('
') &&
+ !processedContent.includes('') &&
+ !processedContent.includes('')
+ ) {
processedContent = `
${processedContent}
`
}
-
+
return processedContent
}
const renderMessage = (message: Message) => {
if (message.isStreaming && !message.content) {
return (
-
-
-
+
+
+
-
-
-
Copilot
-
{formatTimestamp(message.timestamp)}
+
+
+ Copilot
+
+ {formatTimestamp(message.timestamp)}
+
-
-
-
Searching documentation...
+
+
+ Searching documentation...
@@ -265,205 +287,220 @@ export const Copilot = forwardRef
(({ panelWidth }, ref
}
return (
-
-
+
+
{message.role === 'user' ? (
-
+
) : (
-
+
)}
-
-
-
+
+
+
{message.role === 'user' ? 'You' : 'Copilot'}
-
{formatTimestamp(message.timestamp)}
+
+ {formatTimestamp(message.timestamp)}
+
{message.isStreaming && (
-
-
-
Responding...
+
+
+ Responding...
)}
-
+
{/* Enhanced content rendering with inline citations */}
-
-
-
+
{/* Streaming cursor */}
{message.isStreaming && message.content && (
-
+
)}
)
}
- return (
+ return (
<>
{/* Main Panel Content */}
-
+
{/* Header */}
-
-
-
-
+
+
+
+
-
Documentation Copilot
-
Ask questions about Sim Studio
+
Documentation Copilot
+
Ask questions about Sim Studio
-
+
{/* Messages */}
-
+
{messages.length === 0 ? (
-
-
-
Welcome to Documentation Copilot
-
+
+
+
Welcome to Documentation Copilot
+
Ask me anything about Sim Studio features, workflows, tools, or how to get started.
-
-
Try asking:
-
-
"How do I create a workflow?"
-
"What tools are available?"
-
"How do I deploy my workflow?"
+
+
Try asking:
+
+
+ "How do I create a workflow?"
+
+
+ "What tools are available?"
+
+
+ "How do I deploy my workflow?"
+
) : (
-
- {messages.map(renderMessage)}
-
+
{messages.map(renderMessage)}
)}
{/* Input */}
-
-
+
{/* Fullscreen Modal */}
{isFullscreen && (