mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(chat): add 'add inputs' button to chat window (#2057)
* improvement(ui): workflow-block border * feat(chat): add inputs button * added socket event, reused existing utils to persist chat inputs --------- Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
@@ -281,6 +281,8 @@
|
||||
--c-F4F4F4: #f4f4f4;
|
||||
--c-F5F5F5: #f5f5f5;
|
||||
|
||||
--c-CFCFCF: #cfcfcf;
|
||||
|
||||
/* Blues and cyans */
|
||||
--c-00B0B0: #00b0b0;
|
||||
--c-264F78: #264f78;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertCircle, ArrowDownToLine, ArrowUp, MoreVertical, Paperclip, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
PopoverTrigger,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractBlockIdFromOutputId,
|
||||
@@ -21,13 +21,19 @@ import {
|
||||
parseOutputContentSafely,
|
||||
} from '@/lib/response-format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
|
||||
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers'
|
||||
import { START_BLOCK_RESERVED_FIELDS } from '@/lib/workflows/types'
|
||||
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { getChatPosition, useChatStore } from '@/stores/chat/store'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useOperationQueue } from '@/stores/operation-queue/store'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { ChatMessage, OutputSelect } from './components'
|
||||
import { useChatBoundarySync, useChatDrag, useChatFileUpload, useChatResize } from './hooks'
|
||||
|
||||
@@ -124,6 +130,14 @@ const formatOutputContent = (output: any): string => {
|
||||
return ''
|
||||
}
|
||||
|
||||
interface StartInputFormatField {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
value?: unknown
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating chat modal component
|
||||
*
|
||||
@@ -137,9 +151,10 @@ const formatOutputContent = (output: any): string => {
|
||||
* position across sessions using the floating chat store.
|
||||
*/
|
||||
export function Chat() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate)
|
||||
const setSubBlockValue = useSubBlockStore((state) => state.setValue)
|
||||
|
||||
// Chat state (UI and messages from unified store)
|
||||
const {
|
||||
@@ -164,6 +179,8 @@ export function Chat() {
|
||||
const { entries } = useTerminalConsoleStore()
|
||||
const { isExecuting } = useExecutionStore()
|
||||
const { handleRunWorkflow } = useWorkflowExecution()
|
||||
const { data: session } = useSession()
|
||||
const { addToQueue } = useOperationQueue()
|
||||
|
||||
// Local state
|
||||
const [chatMessage, setChatMessage] = useState('')
|
||||
@@ -190,6 +207,71 @@ export function Chat() {
|
||||
handleDrop,
|
||||
} = useChatFileUpload()
|
||||
|
||||
/**
|
||||
* Resolves the unified start block for chat execution, if available.
|
||||
*/
|
||||
const startBlockCandidate = useMemo(() => {
|
||||
if (!activeWorkflowId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!blocks || Object.keys(blocks).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = TriggerUtils.findStartBlock(blocks, 'chat')
|
||||
if (!candidate || candidate.path !== StartBlockPath.UNIFIED) {
|
||||
return null
|
||||
}
|
||||
|
||||
return candidate
|
||||
}, [activeWorkflowId, blocks])
|
||||
|
||||
const startBlockId = startBlockCandidate?.blockId ?? null
|
||||
|
||||
/**
|
||||
* Reads the current input format for the unified start block from the subblock store,
|
||||
* falling back to the workflow store if no explicit value is stored yet.
|
||||
*/
|
||||
const startBlockInputFormat = useSubBlockStore((state) => {
|
||||
if (!activeWorkflowId || !startBlockId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workflowValues = state.workflowValues[activeWorkflowId]
|
||||
const fromStore = workflowValues?.[startBlockId]?.inputFormat
|
||||
if (fromStore !== undefined && fromStore !== null) {
|
||||
return fromStore
|
||||
}
|
||||
|
||||
const startBlock = blocks[startBlockId]
|
||||
return startBlock?.subBlocks?.inputFormat?.value ?? null
|
||||
})
|
||||
|
||||
/**
|
||||
* Determines which reserved start inputs are missing from the input format.
|
||||
*/
|
||||
const missingStartReservedFields = useMemo(() => {
|
||||
if (!startBlockId) {
|
||||
return START_BLOCK_RESERVED_FIELDS
|
||||
}
|
||||
|
||||
const normalizedFields = normalizeInputFormatValue(startBlockInputFormat)
|
||||
const existingNames = new Set(
|
||||
normalizedFields
|
||||
.map((field) => field.name)
|
||||
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
)
|
||||
|
||||
return START_BLOCK_RESERVED_FIELDS.filter(
|
||||
(fieldName) => !existingNames.has(fieldName.toLowerCase())
|
||||
)
|
||||
}, [startBlockId, startBlockInputFormat])
|
||||
|
||||
const shouldShowConfigureStartInputsButton =
|
||||
Boolean(startBlockId) && missingStartReservedFields.length > 0
|
||||
|
||||
// Get actual position (default if not set)
|
||||
const actualPosition = useMemo(
|
||||
() => getChatPosition(chatPosition, chatWidth, chatHeight),
|
||||
@@ -564,7 +646,67 @@ export function Chat() {
|
||||
setIsChatOpen(false)
|
||||
}, [setIsChatOpen])
|
||||
|
||||
// Don't render if not open
|
||||
/**
|
||||
* Adds any missing reserved inputs (input, conversationId, files) to the unified start block.
|
||||
*/
|
||||
const handleConfigureStartInputs = useCallback(() => {
|
||||
if (!activeWorkflowId || !startBlockId) {
|
||||
logger.warn('Cannot configure start inputs: missing active workflow ID or start block ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat)
|
||||
|
||||
const newReservedFields: StartInputFormatField[] = missingStartReservedFields.map(
|
||||
(fieldName) => {
|
||||
const defaultType = fieldName === 'files' ? 'files' : 'string'
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: fieldName,
|
||||
type: defaultType,
|
||||
value: '',
|
||||
collapsed: false,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const updatedFields: StartInputFormatField[] = [...newReservedFields, ...normalizedExisting]
|
||||
|
||||
setSubBlockValue(startBlockId, 'inputFormat', updatedFields)
|
||||
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'subblock-update',
|
||||
target: 'subblock',
|
||||
payload: {
|
||||
blockId: startBlockId,
|
||||
subblockId: 'inputFormat',
|
||||
value: updatedFields,
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
triggerWorkflowUpdate()
|
||||
} catch (error) {
|
||||
logger.error('Failed to configure start block reserved inputs', error)
|
||||
}
|
||||
}, [
|
||||
activeWorkflowId,
|
||||
missingStartReservedFields,
|
||||
setSubBlockValue,
|
||||
startBlockId,
|
||||
startBlockInputFormat,
|
||||
triggerWorkflowUpdate,
|
||||
session,
|
||||
addToQueue,
|
||||
])
|
||||
|
||||
if (!isChatOpen) return null
|
||||
|
||||
return (
|
||||
@@ -583,17 +725,32 @@ export function Chat() {
|
||||
>
|
||||
{/* Header with drag handle */}
|
||||
<div
|
||||
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between bg-[var(--surface-1)] p-0 active:cursor-grabbing'
|
||||
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between gap-[10px] bg-[var(--surface-1)] p-0 active:cursor-grabbing'
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span className='flex-shrink-0 font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
Chat
|
||||
</span>
|
||||
</div>
|
||||
<span className='flex-shrink-0 pr-[2px] font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
Chat
|
||||
</span>
|
||||
|
||||
{/* Start inputs button and output selector - with max-width to prevent overflow */}
|
||||
<div
|
||||
className='ml-auto flex min-w-0 flex-shrink items-center gap-[6px]'
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{shouldShowConfigureStartInputsButton && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
|
||||
title='Add chat inputs to Start block'
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
handleConfigureStartInputs()
|
||||
}}
|
||||
>
|
||||
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Output selector - centered with mx-auto */}
|
||||
<div className='mr-[6px] ml-auto' onMouseDown={(e) => e.stopPropagation()}>
|
||||
<OutputSelect
|
||||
workflowId={activeWorkflowId}
|
||||
selectedOutputs={selectedOutputs}
|
||||
@@ -605,7 +762,7 @@ export function Chat() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{/* More menu with actions */}
|
||||
<Popover variant='default'>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -628,22 +785,22 @@ export function Chat() {
|
||||
<PopoverItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (activeWorkflowId) clearChat(activeWorkflowId)
|
||||
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
|
||||
}}
|
||||
disabled={messages.length === 0}
|
||||
disabled={workflowMessages.length === 0}
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
<span>Clear</span>
|
||||
<ArrowDownToLine className='h-[13px] w-[13px]' />
|
||||
<span>Download</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
|
||||
if (activeWorkflowId) clearChat(activeWorkflowId)
|
||||
}}
|
||||
disabled={messages.length === 0}
|
||||
disabled={workflowMessages.length === 0}
|
||||
>
|
||||
<ArrowDownToLine className='h-[14px] w-[14px]' />
|
||||
<span>Download</span>
|
||||
<Trash className='h-[13px] w-[13px]' />
|
||||
<span>Clear</span>
|
||||
</PopoverItem>
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
@@ -662,7 +819,7 @@ export function Chat() {
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
{workflowMessages.length === 0 ? (
|
||||
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
|
||||
No messages yet
|
||||
Workflow input: {'<start.input>'}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollAreaRef} className='h-full overflow-y-auto overflow-x-hidden'>
|
||||
|
||||
@@ -178,16 +178,11 @@ export function useChatResize({
|
||||
(e: MouseEvent) => {
|
||||
if (!isResizingRef.current || !activeDirectionRef.current) return
|
||||
|
||||
const deltaX = e.clientX - resizeStartRef.current.x
|
||||
const deltaY = e.clientY - resizeStartRef.current.y
|
||||
let deltaX = e.clientX - resizeStartRef.current.x
|
||||
let deltaY = e.clientY - resizeStartRef.current.y
|
||||
const initial = initialStateRef.current
|
||||
const direction = activeDirectionRef.current
|
||||
|
||||
let newX = initial.x
|
||||
let newY = initial.y
|
||||
let newWidth = initial.width
|
||||
let newHeight = initial.height
|
||||
|
||||
// Get layout bounds
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
@@ -199,6 +194,56 @@ export function useChatResize({
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
// Clamp vertical drag when resizing from the top so the chat does not grow downward
|
||||
// after its top edge hits the top of the viewport.
|
||||
if (direction === 'top' || direction === 'top-left' || direction === 'top-right') {
|
||||
// newY = initial.y + deltaY should never be less than 0
|
||||
const maxUpwardDelta = initial.y
|
||||
if (deltaY < -maxUpwardDelta) {
|
||||
deltaY = -maxUpwardDelta
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp vertical drag when resizing from the bottom so the chat does not grow upward
|
||||
// after its bottom edge hits the top of the terminal.
|
||||
if (direction === 'bottom' || direction === 'bottom-left' || direction === 'bottom-right') {
|
||||
const maxBottom = window.innerHeight - terminalHeight
|
||||
const initialBottom = initial.y + initial.height
|
||||
const maxDeltaY = maxBottom - initialBottom
|
||||
|
||||
if (deltaY > maxDeltaY) {
|
||||
deltaY = maxDeltaY
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp horizontal drag when resizing from the left so the chat does not grow to the right
|
||||
// after its left edge hits the sidebar.
|
||||
if (direction === 'left' || direction === 'top-left' || direction === 'bottom-left') {
|
||||
const minLeft = sidebarWidth
|
||||
const minDeltaX = minLeft - initial.x
|
||||
|
||||
if (deltaX < minDeltaX) {
|
||||
deltaX = minDeltaX
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp horizontal drag when resizing from the right so the chat does not grow to the left
|
||||
// after its right edge hits the panel.
|
||||
if (direction === 'right' || direction === 'top-right' || direction === 'bottom-right') {
|
||||
const maxRight = window.innerWidth - panelWidth
|
||||
const initialRight = initial.x + initial.width
|
||||
const maxDeltaX = maxRight - initialRight
|
||||
|
||||
if (deltaX > maxDeltaX) {
|
||||
deltaX = maxDeltaX
|
||||
}
|
||||
}
|
||||
|
||||
let newX = initial.x
|
||||
let newY = initial.y
|
||||
let newWidth = initial.width
|
||||
let newHeight = initial.height
|
||||
|
||||
// Calculate new dimensions based on resize direction
|
||||
switch (direction) {
|
||||
// Corners
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowDown, Braces, Square } from 'lucide-react'
|
||||
import { Braces, Square } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
BubbleChatPreview,
|
||||
@@ -270,14 +270,6 @@ export function Panel() {
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles triggering file input for workflow import
|
||||
*/
|
||||
const handleImportWorkflow = useCallback(() => {
|
||||
setIsMenuOpen(false)
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
// Compute run button state
|
||||
const canRun = userPermissions.canRead // Running only requires read permissions
|
||||
const isLoadingPermissions = userPermissions.isLoading
|
||||
@@ -349,13 +341,6 @@ export function Panel() {
|
||||
<Braces className='h-3 w-3' />
|
||||
<span>Export workflow</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={handleImportWorkflow}
|
||||
disabled={isImporting || !userPermissions.canEdit}
|
||||
>
|
||||
<ArrowDown className='h-3 w-3' />
|
||||
<span>Import workflow</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={handleDuplicateWorkflow}
|
||||
disabled={!userPermissions.canEdit || isDuplicating}
|
||||
@@ -522,16 +507,6 @@ export function Panel() {
|
||||
|
||||
{/* Floating Variables Modal */}
|
||||
<Variables />
|
||||
|
||||
{/* Hidden file input for workflow import */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.json,.zip'
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -721,7 +721,8 @@ export function Terminal() {
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ minWidth: '120px', maxWidth: '120px' }}
|
||||
minWidth={120}
|
||||
maxWidth={200}
|
||||
>
|
||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||
{uniqueBlocks.map((block, index) => {
|
||||
|
||||
@@ -794,7 +794,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
ref={contentRef}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] bg-[var(--surface-2)]'
|
||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'
|
||||
)}
|
||||
>
|
||||
{isPending && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
|
||||
import { classifyStartBlockType, StartBlockPath, TRIGGER_TYPES } from '@/lib/workflows/triggers'
|
||||
import {
|
||||
type InputFormatField,
|
||||
@@ -23,24 +24,6 @@ const UNIFIED_START_OUTPUTS: OutputDefinition = {
|
||||
files: { type: 'files', description: 'User uploaded files' },
|
||||
}
|
||||
|
||||
function normalizeInputFormatValue(inputFormatValue: any): InputFormatField[] {
|
||||
if (
|
||||
inputFormatValue === null ||
|
||||
inputFormatValue === undefined ||
|
||||
(Array.isArray(inputFormatValue) && inputFormatValue.length === 0)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!Array.isArray(inputFormatValue)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return inputFormatValue.filter(
|
||||
(field) => field && typeof field === 'object' && field.name && field.name.trim() !== ''
|
||||
)
|
||||
}
|
||||
|
||||
function applyInputFormatFields(
|
||||
inputFormat: InputFormatField[],
|
||||
outputs: OutputDefinition
|
||||
|
||||
39
apps/sim/lib/workflows/input-format-utils.ts
Normal file
39
apps/sim/lib/workflows/input-format-utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
|
||||
/**
|
||||
* Normalizes an input format value into a list of valid fields.
|
||||
*
|
||||
* Filters out:
|
||||
* - null or undefined values
|
||||
* - Empty arrays
|
||||
* - Non-array values
|
||||
* - Fields without names
|
||||
* - Fields with empty or whitespace-only names
|
||||
*
|
||||
* @param inputFormatValue - Raw input format value from subblock state
|
||||
* @returns Array of validated input format fields
|
||||
*/
|
||||
export function normalizeInputFormatValue(inputFormatValue: unknown): InputFormatField[] {
|
||||
// Handle null, undefined, and empty arrays
|
||||
if (
|
||||
inputFormatValue === null ||
|
||||
inputFormatValue === undefined ||
|
||||
(Array.isArray(inputFormatValue) && inputFormatValue.length === 0)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle non-array values
|
||||
if (!Array.isArray(inputFormatValue)) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter valid fields
|
||||
return inputFormatValue.filter(
|
||||
(field): field is InputFormatField =>
|
||||
field &&
|
||||
typeof field === 'object' &&
|
||||
typeof field.name === 'string' &&
|
||||
field.name.trim() !== ''
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const MAX_MESSAGES = 50
|
||||
/**
|
||||
* Floating chat dimensions
|
||||
*/
|
||||
const DEFAULT_WIDTH = 250
|
||||
const DEFAULT_WIDTH = 330
|
||||
const DEFAULT_HEIGHT = 286
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user