diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx new file mode 100644 index 0000000000..d431a29858 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx @@ -0,0 +1,287 @@ +'use client' + +import { type KeyboardEvent, useEffect, useRef, useState } from 'react' +import { ArrowUp, Bot, User, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('CopilotModal') + +interface Message { + id: string + content: string + type: 'user' | 'assistant' + timestamp: Date + citations?: Array<{ + id: number + title: string + url: string + }> +} + +interface CopilotModalMessage { + message: Message +} + +// Modal-specific message component +function ModalCopilotMessage({ message }: CopilotModalMessage) { + const renderCitations = (text: string, citations?: Array<{ id: number; title: string; url: string }>) => { + if (!citations || citations.length === 0) return text + + let processedText = text + citations.forEach((citation) => { + const citationRegex = new RegExp(`\\{cite:${citation.id}\\}`, 'g') + processedText = processedText.replace( + citationRegex, + `↗` + ) + }) + + return processedText + } + + const renderMarkdown = (text: string) => { + // Handle citations first + let processedText = renderCitations(text, message.citations) + + // Handle code blocks + processedText = processedText.replace( + /```(\w+)?\n([\s\S]*?)\n```/g, + '
$2
' + ) + + // Handle inline code + processedText = processedText.replace(/`([^`]+)`/g, '$1') + + // Handle headers + processedText = processedText.replace(/^### (.*$)/gm, '

$1

') + processedText = processedText.replace(/^## (.*$)/gm, '

$1

') + processedText = processedText.replace(/^# (.*$)/gm, '

$1

') + + // Handle bold + processedText = processedText.replace(/\*\*(.*?)\*\*/g, '$1') + + // Handle lists + processedText = processedText.replace(/^- (.*$)/gm, '
  • • $1
  • ') + + // Handle line breaks + processedText = processedText.replace(/\n/g, '
    ') + + return processedText + } + + // For user messages (on the right) + if (message.type === 'user') { + return ( +
    +
    +
    +
    +
    + {message.content} +
    +
    +
    +
    +
    + ) + } + + // For assistant messages (on the left) + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    + ) +} + +interface CopilotModalProps { + open: boolean + onOpenChange: (open: boolean) => void + copilotMessage: string + setCopilotMessage: (message: string) => void + messages: Message[] + onSendMessage: (message: string) => Promise + isLoading: boolean +} + +export function CopilotModal({ + open, + onOpenChange, + copilotMessage, + setCopilotMessage, + messages, + onSendMessage, + isLoading +}: CopilotModalProps) { + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + const inputRef = useRef(null) + + // Auto-scroll to bottom when new messages are added + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [messages]) + + // Focus input when modal opens + useEffect(() => { + if (open && inputRef.current) { + inputRef.current.focus() + } + }, [open]) + + // Handle send message + const handleSendMessage = async () => { + if (!copilotMessage.trim() || isLoading) return + + try { + await onSendMessage(copilotMessage.trim()) + setCopilotMessage('') + + // Ensure input stays focused + if (inputRef.current) { + inputRef.current.focus() + } + } catch (error) { + logger.error('Failed to send message', error) + } + } + + // Handle key press + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + if (!open) return null + + return ( +
    + + + {/* Header with title and close button */} +
    +

    Documentation Copilot

    + +
    + + {/* Messages container */} +
    +
    + {messages.length === 0 ? ( +
    +
    + +
    +

    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?" +
    +
    +
    +
    +
    + ) : ( + messages.map((message) => ( + + )) + )} + + {/* Loading indicator (shows only when loading) */} + {isLoading && ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + )} + +
    +
    +
    + + {/* Input area (fixed at bottom) */} +
    +
    +
    + setCopilotMessage(e.target.value)} + onKeyDown={handleKeyPress} + placeholder='Ask about Sim Studio documentation...' + className='min-h-[50px] flex-1 rounded-2xl border-0 bg-transparent py-7 pr-16 pl-6 text-base focus-visible:ring-0 focus-visible:ring-offset-0' + disabled={isLoading} + /> + +
    + +
    +

    Ask questions about Sim Studio documentation and features

    +
    +
    +
    +
    + ) +} \ 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 e9e536e000..2627fc004c 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,17 +1,21 @@ 'use client' import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' -import { Bot, Expand, Loader2, Send, User, X } from 'lucide-react' +import { Bot, Loader2, Send, User } 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 { createLogger } from '@/lib/logs/console-logger' +import { CopilotModal } from './components/copilot-modal/copilot-modal' const logger = createLogger('Copilot') interface CopilotProps { panelWidth: number + isFullscreen?: boolean + onFullscreenToggle?: (fullscreen: boolean) => void + fullscreenInput?: string + onFullscreenInputChange?: (input: string) => void } interface CopilotRef { @@ -33,11 +37,16 @@ interface Message { isStreaming?: boolean } -export const Copilot = forwardRef(({ panelWidth }, ref) => { +export const Copilot = forwardRef(({ + panelWidth, + isFullscreen = false, + onFullscreenToggle, + fullscreenInput = '', + onFullscreenInputChange +}, ref) => { const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [isLoading, setIsLoading] = useState(false) - const [isFullscreen, setIsFullscreen] = useState(false) const scrollAreaRef = useRef(null) const inputRef = useRef(null) @@ -334,30 +343,159 @@ export const Copilot = forwardRef(({ panelWidth }, ref ) } + // Convert messages for modal (role -> type) + const modalMessages = messages.map(msg => ({ + id: msg.id, + content: msg.content, + type: msg.role as 'user' | 'assistant', + timestamp: msg.timestamp, + citations: msg.sources?.map((source, index) => ({ + id: index + 1, + title: source.title, + url: source.link + })) + })) + + // Handle modal message sending + const handleModalSendMessage = useCallback(async (message: string) => { + // Use the same handleSubmit logic but with the message parameter + const userMessage: Message = { + id: crypto.randomUUID(), + role: 'user', + content: message, + timestamp: new Date(), + } + + const streamingMessage: Message = { + id: crypto.randomUUID(), + role: 'assistant', + content: '', + timestamp: new Date(), + isStreaming: true, + } + + setMessages((prev) => [...prev, userMessage, streamingMessage]) + setIsLoading(true) + + try { + logger.info('Sending docs RAG query:', { query: message }) + + const response = await fetch('/api/docs/ask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: message, + topK: 5, + stream: true, + }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.text()}`) + } + + // Handle streaming response + if (response.headers.get('content-type')?.includes('text/event-stream')) { + const reader = response.body?.getReader() + const decoder = new TextDecoder() + let accumulatedContent = '' + let sources: any[] = [] + + if (!reader) { + throw new Error('Failed to get response reader') + } + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const lines = chunk.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)) + + if (data.type === 'metadata') { + sources = data.sources || [] + } else if (data.type === 'content') { + accumulatedContent += data.content + + // Update the streaming message with accumulated content + setMessages((prev) => + prev.map((msg) => + msg.id === streamingMessage.id + ? { ...msg, content: accumulatedContent, sources } + : msg + ) + ) + } else if (data.type === 'done') { + // Finish streaming + setMessages((prev) => + prev.map((msg) => + msg.id === streamingMessage.id + ? { ...msg, isStreaming: false, sources } + : msg + ) + ) + } else if (data.type === 'error') { + throw new Error(data.error || 'Streaming error') + } + } catch (parseError) { + logger.warn('Failed to parse SSE data:', parseError) + } + } + } + } + + logger.info('Received docs RAG response:', { + contentLength: accumulatedContent.length, + sourcesCount: sources.length, + }) + } else { + // Fallback to non-streaming response + const data = await response.json() + + const assistantMessage: Message = { + id: streamingMessage.id, + role: 'assistant', + content: data.response || 'Sorry, I could not generate a response.', + timestamp: new Date(), + sources: data.sources || [], + isStreaming: false, + } + + setMessages((prev) => prev.slice(0, -1).concat(assistantMessage)) + } + } catch (error) { + logger.error('Docs RAG error:', error) + + const errorMessage: Message = { + id: streamingMessage.id, + role: 'assistant', + content: + 'Sorry, I encountered an error while searching the documentation. Please try again.', + timestamp: new Date(), + isStreaming: false, + } + + setMessages((prev) => prev.slice(0, -1).concat(errorMessage)) + } finally { + setIsLoading(false) + } + }, []) + return ( <> - {/* Main Panel Content */}
    {/* Header */}
    -
    -
    - -
    -

    Documentation Copilot

    -

    Ask questions about Sim Studio

    -
    -
    -
    - +
    + +
    +

    Documentation Copilot

    +

    Ask questions about Sim Studio

    @@ -420,95 +558,15 @@ export const Copilot = forwardRef(({ panelWidth }, ref
    {/* Fullscreen Modal */} - {isFullscreen && ( - - -
    - {/* Header */} -
    -
    -
    - -
    -

    Documentation Copilot

    -

    - Ask questions about Sim Studio -

    -
    -
    -
    - -
    -
    -
    - - {/* Messages */} - - {messages.length === 0 ? ( -
    - -

    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?" -
    -
    -
    -
    - ) : ( -
    {messages.map(renderMessage)}
    - )} -
    - - {/* Input */} -
    -
    - setInput(e.target.value)} - placeholder='Ask about Sim Studio documentation...' - disabled={isLoading} - className='flex-1' - autoComplete='off' - /> - -
    -
    -
    -
    -
    - )} + onFullscreenToggle?.(open)} + copilotMessage={fullscreenInput} + setCopilotMessage={(message) => onFullscreenInputChange?.(message)} + messages={modalMessages} + onSendMessage={handleModalSendMessage} + isLoading={isLoading} + /> ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 8398d5bb56..5a22b6905c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -17,7 +17,9 @@ export function Panel() { const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width) const [isDragging, setIsDragging] = useState(false) const [chatMessage, setChatMessage] = useState('') + const [copilotMessage, setCopilotMessage] = useState('') const [isChatModalOpen, setIsChatModalOpen] = useState(false) + const [isCopilotModalOpen, setIsCopilotModalOpen] = useState(false) const copilotRef = useRef<{ clearMessages: () => void }>(null) const isOpen = usePanelStore((state) => state.isOpen) @@ -155,7 +157,14 @@ export function Panel() { ) : activeTab === 'console' ? ( ) : activeTab === 'copilot' ? ( - + ) : ( )} @@ -190,6 +199,21 @@ export function Panel() { Expand Chat )} + + {activeTab === 'copilot' && ( + + + + + Expand Copilot + + )}