mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
feat(workflow-chat) (#269)
* feat(workflow-chat): added control bar switch * feat(workflow-chat): finished UI * feat(workflow-chat): added logic to execute workflows and return value selected
This commit is contained in:
@@ -898,6 +898,9 @@ export function ControlBar() {
|
||||
'bg-[#802FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
(isExecuting || isMultiRunning) &&
|
||||
!isCancelling &&
|
||||
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none',
|
||||
'rounded-l-none h-10'
|
||||
)}
|
||||
|
||||
343
sim/app/w/[id]/components/panel/components/chat/chat.tsx
Normal file
343
sim/app/w/[id]/components/panel/components/chat/chat.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
'use client'
|
||||
|
||||
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUp, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useWorkflowExecution } from '../../../../hooks/use-workflow-execution'
|
||||
import { ChatMessage } from './components/chat-message'
|
||||
|
||||
interface ChatProps {
|
||||
panelWidth: number
|
||||
chatMessage: string
|
||||
setChatMessage: (message: string) => void
|
||||
}
|
||||
|
||||
export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { messages, addMessage, selectedWorkflowOutputs, setSelectedWorkflowOutput } =
|
||||
useChatStore()
|
||||
const { entries } = useConsoleStore()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Use the execution store state to track if a workflow is executing
|
||||
const { isExecuting } = useExecutionStore()
|
||||
|
||||
// Get workflow execution functionality
|
||||
const { handleRunWorkflow, executionResult } = useWorkflowExecution()
|
||||
|
||||
// Get workflow outputs for the dropdown
|
||||
const workflowOutputs = useMemo(() => {
|
||||
const outputs: {
|
||||
id: string
|
||||
label: string
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
path: string
|
||||
}[] = []
|
||||
|
||||
if (!activeWorkflowId) return outputs
|
||||
|
||||
// Process blocks to extract outputs
|
||||
Object.values(blocks).forEach((block) => {
|
||||
const blockName = block.name.replace(/\s+/g, '').toLowerCase()
|
||||
|
||||
// Add response outputs
|
||||
if (block.outputs && typeof block.outputs === 'object') {
|
||||
const addOutput = (path: string, outputObj: any, prefix = '') => {
|
||||
const fullPath = prefix ? `${prefix}.${path}` : path
|
||||
|
||||
if (typeof outputObj === 'object' && outputObj !== null) {
|
||||
// For objects, recursively add each property
|
||||
Object.entries(outputObj).forEach(([key, value]) => {
|
||||
addOutput(key, value, fullPath)
|
||||
})
|
||||
} else {
|
||||
// Add leaf node as output option
|
||||
outputs.push({
|
||||
id: `${block.id}_${fullPath}`,
|
||||
label: `${blockName}.${fullPath}`,
|
||||
blockId: block.id,
|
||||
blockName: block.name,
|
||||
blockType: block.type,
|
||||
path: fullPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Start with the response object
|
||||
if (block.outputs.response) {
|
||||
addOutput('response', block.outputs.response)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return outputs
|
||||
}, [blocks, activeWorkflowId])
|
||||
|
||||
// Get output entries from console for the dropdown
|
||||
const outputEntries = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return entries.filter((entry) => entry.workflowId === activeWorkflowId && entry.output)
|
||||
}, [entries, activeWorkflowId])
|
||||
|
||||
// Get filtered messages for current workflow
|
||||
const workflowMessages = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return messages
|
||||
.filter((msg) => msg.workflowId === activeWorkflowId)
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
}, [messages, activeWorkflowId])
|
||||
|
||||
// Get selected workflow output
|
||||
const selectedOutput = useMemo(() => {
|
||||
if (!activeWorkflowId) return null
|
||||
const selectedId = selectedWorkflowOutputs[activeWorkflowId]
|
||||
if (!selectedId) return outputEntries[0]?.id || null
|
||||
return selectedId
|
||||
}, [selectedWorkflowOutputs, activeWorkflowId, outputEntries])
|
||||
|
||||
// Get selected output display name
|
||||
const selectedOutputDisplayName = useMemo(() => {
|
||||
if (!selectedOutput) return 'Select output source'
|
||||
const output = workflowOutputs.find((o) => o.id === selectedOutput)
|
||||
return output
|
||||
? `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}`
|
||||
: 'Select output source'
|
||||
}, [selectedOutput, workflowOutputs])
|
||||
|
||||
// Get selected output block info
|
||||
const selectedOutputInfo = useMemo(() => {
|
||||
if (!selectedOutput) return null
|
||||
const output = workflowOutputs.find((o) => o.id === selectedOutput)
|
||||
if (!output) return null
|
||||
|
||||
return {
|
||||
blockName: output.blockName,
|
||||
blockId: output.blockId,
|
||||
blockType: output.blockType,
|
||||
path: output.path,
|
||||
}
|
||||
}, [selectedOutput, workflowOutputs])
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [workflowMessages])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOutputDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle send message
|
||||
const handleSendMessage = async () => {
|
||||
if (!chatMessage.trim() || !activeWorkflowId || isExecuting) return
|
||||
|
||||
// Store the message being sent for reference
|
||||
const sentMessage = chatMessage.trim()
|
||||
|
||||
// Add user message
|
||||
addMessage({
|
||||
content: sentMessage,
|
||||
workflowId: activeWorkflowId,
|
||||
type: 'user',
|
||||
})
|
||||
|
||||
// Clear input
|
||||
setChatMessage('')
|
||||
|
||||
// Execute the workflow to generate a response, passing the chat message as input
|
||||
// The workflow execution will trigger block executions which will add messages to the chat via the console store
|
||||
await handleRunWorkflow({ input: sentMessage })
|
||||
}
|
||||
|
||||
// Handle key press
|
||||
const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle output selection
|
||||
const handleOutputSelection = (value: string) => {
|
||||
if (activeWorkflowId) {
|
||||
setSelectedWorkflowOutput(activeWorkflowId, value)
|
||||
setIsOutputDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Group output options by block
|
||||
const groupedOutputs = useMemo(() => {
|
||||
const groups: Record<string, typeof workflowOutputs> = {}
|
||||
|
||||
// Group by block name
|
||||
workflowOutputs.forEach((output) => {
|
||||
if (!groups[output.blockName]) {
|
||||
groups[output.blockName] = []
|
||||
}
|
||||
groups[output.blockName].push(output)
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [workflowOutputs])
|
||||
|
||||
// Get block color for an output
|
||||
const getOutputColor = (blockId: string, blockType: string) => {
|
||||
// Try to get the block's color from its configuration
|
||||
const blockConfig = getBlock(blockType)
|
||||
return blockConfig?.bgColor || '#2F55FF' // Default blue if not found
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Output Source Dropdown */}
|
||||
<div className="flex-none border-b px-4 py-2" ref={dropdownRef}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOutputDropdownOpen(!isOutputDropdownOpen)}
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
isOutputDropdownOpen
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
disabled={workflowOutputs.length === 0}
|
||||
>
|
||||
{selectedOutputInfo ? (
|
||||
<div className="flex items-center gap-2 w-[calc(100%-24px)] overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: getOutputColor(
|
||||
selectedOutputInfo.blockId,
|
||||
selectedOutputInfo.blockType
|
||||
),
|
||||
}}
|
||||
>
|
||||
<span className="w-3 h-3 text-white font-bold text-xs">
|
||||
{selectedOutputInfo.blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate">{selectedOutputDisplayName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="truncate w-[calc(100%-24px)]">{selectedOutputDisplayName}</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ml-1 flex-shrink-0 ${
|
||||
isOutputDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOutputDropdownOpen && workflowOutputs.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-popover rounded-md border shadow-md overflow-hidden">
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
|
||||
<div key={blockName}>
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground border-t first:border-t-0">
|
||||
{blockName}
|
||||
</div>
|
||||
<div>
|
||||
{outputs.map((output) => (
|
||||
<button
|
||||
key={output.id}
|
||||
onClick={() => handleOutputSelection(output.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-left w-full px-3 py-1.5',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
|
||||
selectedOutput === output.id && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: getOutputColor(output.blockId, output.blockType),
|
||||
}}
|
||||
>
|
||||
<span className="w-3 h-3 text-white font-bold text-xs">
|
||||
{blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate max-w-[calc(100%-28px)]">{output.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main layout with fixed heights to ensure input stays visible */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Chat messages section - Scrollable area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div>
|
||||
{workflowMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
||||
No messages yet
|
||||
</div>
|
||||
) : (
|
||||
workflowMessages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} containerWidth={panelWidth} />
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Input section - Fixed height */}
|
||||
<div className="flex-none border-t bg-background pt-4 px-4 pb-4 relative -mt-[1px]">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={chatMessage}
|
||||
onChange={(e) => setChatMessage(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 focus-visible:ring-0 focus-visible:ring-offset-0 h-10"
|
||||
disabled={!activeWorkflowId || isExecuting}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
size="icon"
|
||||
disabled={!chatMessage.trim() || !activeWorkflowId || isExecuting}
|
||||
className="h-10 w-10 bg-[#802FFF] hover:bg-[#7028E6] text-white"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { Clock, Terminal, User } from 'lucide-react'
|
||||
import { JSONView } from '../../console/components/json-view/json-view'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: {
|
||||
id: string
|
||||
content: any
|
||||
timestamp: string | Date
|
||||
type: 'user' | 'workflow'
|
||||
}
|
||||
containerWidth: number
|
||||
}
|
||||
|
||||
// Maximum character length for a word before it's broken up
|
||||
const MAX_WORD_LENGTH = 25
|
||||
|
||||
const WordWrap = ({ text }: { text: string }) => {
|
||||
if (!text) return null
|
||||
|
||||
// Split text into words, keeping spaces and punctuation
|
||||
const parts = text.split(/(\s+)/g)
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
// If the part is whitespace or shorter than the max length, render it as is
|
||||
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
|
||||
return <span key={index}>{part}</span>
|
||||
}
|
||||
|
||||
// For long words, break them up into chunks
|
||||
const chunks = []
|
||||
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
|
||||
chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={index} className="break-all">
|
||||
{chunks.map((chunk, chunkIndex) => (
|
||||
<span key={chunkIndex}>{chunk}</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, containerWidth }: ChatMessageProps) {
|
||||
const messageDate = useMemo(() => new Date(message.timestamp), [message.timestamp])
|
||||
|
||||
const relativeTime = useMemo(() => {
|
||||
return formatDistanceToNow(messageDate, { addSuffix: true })
|
||||
}, [messageDate])
|
||||
|
||||
// Check if content is a JSON object
|
||||
const isJsonObject = useMemo(() => {
|
||||
return typeof message.content === 'object' && message.content !== null
|
||||
}, [message.content])
|
||||
|
||||
// Format message content based on type
|
||||
const formattedContent = useMemo(() => {
|
||||
if (isJsonObject) {
|
||||
return JSON.stringify(message.content) // Return stringified version for type safety
|
||||
}
|
||||
|
||||
return String(message.content)
|
||||
}, [message.content, isJsonObject])
|
||||
|
||||
return (
|
||||
<div className="w-full border-b border-border p-4 space-y-4 hover:bg-accent/50 transition-colors">
|
||||
{/* Header with time on left and message type on right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{relativeTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{message.type !== 'user' && <span className="text-muted-foreground">Workflow</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message content with proper word wrapping */}
|
||||
<div className="text-sm font-mono flex-1 break-normal whitespace-normal overflow-wrap-anywhere relative">
|
||||
{isJsonObject ? (
|
||||
<JSONView data={message.content} initiallyExpanded={false} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-foreground break-words">
|
||||
<WordWrap text={formattedContent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function Console({ panelWidth }: ConsoleProps) {
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="pb-16">
|
||||
<div>
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground pt-4">
|
||||
No console entries
|
||||
|
||||
@@ -156,7 +156,7 @@ export function Variables({ panelWidth }: VariablesProps) {
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 pb-16 space-y-3">
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Variables List */}
|
||||
{workflowVariables.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-sm text-muted-foreground pt-4">
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PanelRight } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { usePanelStore } from '../../../../../stores/panel/store'
|
||||
import { Chat } from './components/chat/chat'
|
||||
import { Console } from './components/console/console'
|
||||
import { Variables } from './components/variables/variables'
|
||||
|
||||
export function Panel() {
|
||||
const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [chatMessage, setChatMessage] = useState<string>('')
|
||||
|
||||
const isOpen = usePanelStore((state) => state.isOpen)
|
||||
const togglePanel = usePanelStore((state) => state.togglePanel)
|
||||
@@ -19,6 +22,7 @@ export function Panel() {
|
||||
const setActiveTab = usePanelStore((state) => state.setActiveTab)
|
||||
|
||||
const clearConsole = useConsoleStore((state) => state.clearConsole)
|
||||
const clearChat = useChatStore((state) => state.clearChat)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
@@ -68,7 +72,7 @@ export function Panel() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-0 top-16 z-10 h-[calc(100vh-4rem)] border-l bg-background"
|
||||
className="fixed right-0 top-16 z-10 h-[calc(100vh-4rem)] border-l bg-background flex flex-col"
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
<div
|
||||
@@ -76,8 +80,19 @@ export function Panel() {
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between h-14 px-4 border-b">
|
||||
{/* Panel Header */}
|
||||
<div className="flex-none flex items-center justify-between h-14 px-4 border-b">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('console')}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
@@ -100,9 +115,11 @@ export function Panel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'console' && (
|
||||
{(activeTab === 'console' || activeTab === 'chat') && (
|
||||
<button
|
||||
onClick={() => clearConsole(activeWorkflowId)}
|
||||
onClick={() =>
|
||||
activeTab === 'console' ? clearConsole(activeWorkflowId) : clearChat(activeWorkflowId)
|
||||
}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
true ? 'text-muted-foreground hover:text-foreground hover:bg-accent/50' : ''
|
||||
}`}
|
||||
@@ -112,20 +129,24 @@ export function Panel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(100%-4rem)]">
|
||||
{activeTab === 'console' ? (
|
||||
{/* Panel Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === 'chat' ? (
|
||||
<Chat panelWidth={width} chatMessage={chatMessage} setChatMessage={setChatMessage} />
|
||||
) : activeTab === 'console' ? (
|
||||
<Console panelWidth={width} />
|
||||
) : (
|
||||
<Variables panelWidth={width} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 right-0 bottom-0 h-16 bg-background border-t">
|
||||
{/* Panel Footer */}
|
||||
<div className="flex-none h-16 bg-background border-t flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={togglePanel}
|
||||
className="absolute left-4 bottom-[18px] flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
|
||||
className="ml-4 flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<PanelRight className="h-5 w-5 transform rotate-180" />
|
||||
<span className="sr-only">Close Panel</span>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useWorkflowExecution() {
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { addNotification } = useNotificationStore()
|
||||
const { toggleConsole } = useConsoleStore()
|
||||
const { togglePanel, setActiveTab } = usePanelStore()
|
||||
const { togglePanel, setActiveTab, activeTab } = usePanelStore()
|
||||
const { getAllVariables } = useEnvironmentStore()
|
||||
const { isDebugModeEnabled } = useGeneralStore()
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||
@@ -73,7 +73,7 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunWorkflow = useCallback(async () => {
|
||||
const handleRunWorkflow = useCallback(async (workflowInput?: any) => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
// Reset execution result and set execution state
|
||||
@@ -92,7 +92,9 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
|
||||
// Set active tab to console
|
||||
setActiveTab('console')
|
||||
if (activeTab !== 'console' && activeTab !== 'chat') {
|
||||
setActiveTab('console')
|
||||
}
|
||||
|
||||
const executionId = uuidv4()
|
||||
|
||||
@@ -144,7 +146,7 @@ export function useWorkflowExecution() {
|
||||
workflow,
|
||||
currentBlockStates,
|
||||
envVarValues,
|
||||
undefined,
|
||||
workflowInput,
|
||||
workflowVariables
|
||||
)
|
||||
setExecutor(newExecutor)
|
||||
|
||||
@@ -775,6 +775,7 @@ export class Executor {
|
||||
endedAt: blockLog.endedAt,
|
||||
workflowId: context.workflowId,
|
||||
timestamp: blockLog.startedAt,
|
||||
blockId: block.id,
|
||||
blockName: block.metadata?.name || 'Unnamed Block',
|
||||
blockType: block.metadata?.id || 'unknown',
|
||||
})
|
||||
|
||||
60
sim/stores/panel/chat/store.ts
Normal file
60
sim/stores/panel/chat/store.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { ChatMessage, ChatStore } from './types'
|
||||
|
||||
// MAX across all workflows
|
||||
const MAX_MESSAGES = 50
|
||||
|
||||
export const useChatStore = create<ChatStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: [],
|
||||
selectedWorkflowOutputs: {},
|
||||
|
||||
addMessage: (message) => {
|
||||
set((state) => {
|
||||
const newMessage: ChatMessage = {
|
||||
...message,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Keep only the last MAX_MESSAGES
|
||||
const newMessages = [newMessage, ...state.messages].slice(0, MAX_MESSAGES)
|
||||
|
||||
return { messages: newMessages }
|
||||
})
|
||||
},
|
||||
|
||||
clearChat: (workflowId: string | null) => {
|
||||
set((state) => ({
|
||||
messages: state.messages.filter(
|
||||
(message) => !workflowId || message.workflowId !== workflowId
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
getWorkflowMessages: (workflowId) => {
|
||||
return get().messages.filter((message) => message.workflowId === workflowId)
|
||||
},
|
||||
|
||||
setSelectedWorkflowOutput: (workflowId, outputId) => {
|
||||
set((state) => ({
|
||||
selectedWorkflowOutputs: {
|
||||
...state.selectedWorkflowOutputs,
|
||||
[workflowId]: outputId,
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
getSelectedWorkflowOutput: (workflowId) => {
|
||||
return get().selectedWorkflowOutputs[workflowId] || null
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'chat-store',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
18
sim/stores/panel/chat/types.ts
Normal file
18
sim/stores/panel/chat/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: any
|
||||
workflowId: string | null
|
||||
type: 'user' | 'workflow'
|
||||
timestamp: string
|
||||
blockId?: string
|
||||
}
|
||||
|
||||
export interface ChatStore {
|
||||
messages: ChatMessage[]
|
||||
selectedWorkflowOutputs: Record<string, string>
|
||||
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'>) => void
|
||||
clearChat: (workflowId: string | null) => void
|
||||
getWorkflowMessages: (workflowId: string) => ChatMessage[]
|
||||
setSelectedWorkflowOutput: (workflowId: string, outputId: string) => void
|
||||
getSelectedWorkflowOutput: (workflowId: string) => string | null
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { ConsoleEntry, ConsoleStore } from './types'
|
||||
import { useChatStore } from '../chat/store'
|
||||
|
||||
// MAX across all workflows
|
||||
const MAX_ENTRIES = 50
|
||||
@@ -39,6 +40,28 @@ const redactApiKeys = (obj: any): any => {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a nested property value from an object using a path string
|
||||
* @param obj The object to get the value from
|
||||
* @param path The path to the value (e.g. 'response.content')
|
||||
* @returns The value at the path, or undefined if not found
|
||||
*/
|
||||
const getValueByPath = (obj: any, path: string): any => {
|
||||
if (!obj || !path) return undefined;
|
||||
|
||||
const pathParts = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export const useConsoleStore = create<ConsoleStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
@@ -65,6 +88,57 @@ export const useConsoleStore = create<ConsoleStore>()(
|
||||
// Keep only the last MAX_ENTRIES
|
||||
const newEntries = [newEntry, ...state.entries].slice(0, MAX_ENTRIES)
|
||||
|
||||
// Check if this block matches a selected workflow output
|
||||
if (entry.workflowId && entry.blockName) {
|
||||
const chatStore = useChatStore.getState()
|
||||
const selectedOutputId = chatStore.getSelectedWorkflowOutput(entry.workflowId)
|
||||
|
||||
if (selectedOutputId) {
|
||||
// The selectedOutputId format is "{blockId}_{path}"
|
||||
// We need to extract both components
|
||||
const idParts = selectedOutputId.split('_');
|
||||
const selectedBlockId = idParts[0];
|
||||
// Reconstruct the path by removing the blockId part
|
||||
const selectedPath = idParts.slice(1).join('.');
|
||||
|
||||
console.log(`[Chat Output] Selected Output ID: ${selectedOutputId}`);
|
||||
console.log(`[Chat Output] Block ID: ${selectedBlockId}, Path: ${selectedPath}`);
|
||||
console.log(`[Chat Output] Current Block ID: ${entry.blockId}`);
|
||||
|
||||
// If this block matches the selected output for this workflow
|
||||
if (selectedBlockId && entry.blockId === selectedBlockId) {
|
||||
// Extract the specific value from the output using the path
|
||||
let specificValue: any = undefined;
|
||||
|
||||
if (selectedPath) {
|
||||
specificValue = getValueByPath(entry.output, selectedPath);
|
||||
console.log(`[Chat Output] Found value:`, specificValue);
|
||||
} else {
|
||||
console.log(`[Chat Output] No path specified, using entire output`);
|
||||
specificValue = entry.output;
|
||||
}
|
||||
|
||||
// Format the value appropriately for display
|
||||
let formattedValue: string;
|
||||
if (specificValue === undefined) {
|
||||
formattedValue = "Output value not found";
|
||||
} else if (typeof specificValue === 'object') {
|
||||
formattedValue = JSON.stringify(specificValue, null, 2);
|
||||
} else {
|
||||
formattedValue = String(specificValue);
|
||||
}
|
||||
|
||||
// Add the specific value to chat, not the whole output
|
||||
chatStore.addMessage({
|
||||
content: formattedValue,
|
||||
workflowId: entry.workflowId,
|
||||
type: 'workflow',
|
||||
blockId: entry.blockId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: newEntries }
|
||||
})
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ConsoleEntry {
|
||||
timestamp: string
|
||||
blockName?: string
|
||||
blockType?: string
|
||||
blockId?: string
|
||||
}
|
||||
|
||||
export interface ConsoleStore {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type PanelTab = 'console' | 'variables'
|
||||
export type PanelTab = 'console' | 'variables' | 'chat'
|
||||
|
||||
export interface PanelStore {
|
||||
isOpen: boolean
|
||||
|
||||
Reference in New Issue
Block a user