mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-29 16:58:11 -05:00
Compare commits
4 Commits
v0.5.76
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c3b82339 | ||
|
|
d44c75f486 | ||
|
|
2b026ded16 | ||
|
|
dca0758054 |
@@ -8,11 +8,19 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { ArrowLeftRight, ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import { FileUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||
@@ -27,13 +35,13 @@ const MAX_TEXTAREA_HEIGHT_PX = 320
|
||||
|
||||
/** Pattern to match complete message objects in JSON */
|
||||
const COMPLETE_MESSAGE_PATTERN =
|
||||
/"role"\s*:\s*"(system|user|assistant)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g
|
||||
/"role"\s*:\s*"(system|user|assistant|media)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g
|
||||
|
||||
/** Pattern to match incomplete content at end of buffer */
|
||||
const INCOMPLETE_CONTENT_PATTERN = /"content"\s*:\s*"((?:[^"\\]|\\.)*)$/
|
||||
|
||||
/** Pattern to match role before content */
|
||||
const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*$/
|
||||
const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant|media)"[^{]*$/
|
||||
|
||||
/**
|
||||
* Unescapes JSON string content
|
||||
@@ -41,41 +49,40 @@ const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*
|
||||
const unescapeContent = (str: string): string =>
|
||||
str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
||||
|
||||
|
||||
/**
|
||||
* Media content for multimodal messages
|
||||
*/
|
||||
interface MediaContent {
|
||||
data: string
|
||||
mimeType?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for individual message in the messages array
|
||||
*/
|
||||
interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
role: 'system' | 'user' | 'assistant' | 'media'
|
||||
content: string
|
||||
media?: MediaContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the MessagesInput component
|
||||
*/
|
||||
interface MessagesInputProps {
|
||||
/** Unique identifier for the block */
|
||||
blockId: string
|
||||
/** Unique identifier for the sub-block */
|
||||
subBlockId: string
|
||||
/** Configuration object for the sub-block */
|
||||
config: SubBlockConfig
|
||||
/** Whether component is in preview mode */
|
||||
isPreview?: boolean
|
||||
/** Value to display in preview mode */
|
||||
previewValue?: Message[] | null
|
||||
/** Whether the input is disabled */
|
||||
disabled?: boolean
|
||||
/** Ref to expose wand control handlers to parent */
|
||||
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* MessagesInput component for managing LLM message history
|
||||
*
|
||||
* @remarks
|
||||
* - Manages an array of messages with role and content
|
||||
* - Each message can be edited, removed, or reordered
|
||||
* - Stores data in LLM-compatible format: [{ role, content }]
|
||||
*/
|
||||
export function MessagesInput({
|
||||
blockId,
|
||||
@@ -90,6 +97,10 @@ export function MessagesInput({
|
||||
const [localMessages, setLocalMessages] = useState<Message[]>([{ role: 'user', content: '' }])
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const [openPopoverIndex, setOpenPopoverIndex] = useState<number | null>(null)
|
||||
|
||||
// Local media mode state - basic = FileUpload, advanced = URL/base64 textarea
|
||||
const [mediaMode, setMediaMode] = useState<'basic' | 'advanced'>('basic')
|
||||
|
||||
const subBlockInput = useSubBlockInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
@@ -98,43 +109,38 @@ export function MessagesInput({
|
||||
disabled,
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets the current messages as JSON string for wand context
|
||||
*/
|
||||
const getMessagesJson = useCallback((): string => {
|
||||
if (localMessages.length === 0) return ''
|
||||
// Filter out empty messages for cleaner context
|
||||
const nonEmptyMessages = localMessages.filter((m) => m.content.trim() !== '')
|
||||
if (nonEmptyMessages.length === 0) return ''
|
||||
return JSON.stringify(nonEmptyMessages, null, 2)
|
||||
}, [localMessages])
|
||||
|
||||
/**
|
||||
* Streaming buffer for accumulating JSON content
|
||||
*/
|
||||
const streamBufferRef = useRef<string>('')
|
||||
|
||||
/**
|
||||
* Parses and validates messages from JSON content
|
||||
*/
|
||||
const parseMessages = useCallback((content: string): Message[] | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (Array.isArray(parsed)) {
|
||||
const validMessages: Message[] = parsed
|
||||
.filter(
|
||||
(m): m is { role: string; content: string } =>
|
||||
(m): m is { role: string; content: string; media?: MediaContent } =>
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
typeof m.role === 'string' &&
|
||||
typeof m.content === 'string'
|
||||
)
|
||||
.map((m) => ({
|
||||
role: (['system', 'user', 'assistant'].includes(m.role)
|
||||
? m.role
|
||||
: 'user') as Message['role'],
|
||||
content: m.content,
|
||||
}))
|
||||
.map((m) => {
|
||||
const role = ['system', 'user', 'assistant', 'media'].includes(m.role) ? m.role : 'user'
|
||||
const message: Message = {
|
||||
role: role as Message['role'],
|
||||
content: m.content,
|
||||
}
|
||||
if (m.media) {
|
||||
message.media = m.media
|
||||
}
|
||||
return message
|
||||
})
|
||||
return validMessages.length > 0 ? validMessages : null
|
||||
}
|
||||
} catch {
|
||||
@@ -143,26 +149,19 @@ export function MessagesInput({
|
||||
return null
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Extracts messages from streaming JSON buffer
|
||||
* Uses simple pattern matching for efficiency
|
||||
*/
|
||||
const extractStreamingMessages = useCallback(
|
||||
(buffer: string): Message[] => {
|
||||
// Try complete JSON parse first
|
||||
const complete = parseMessages(buffer)
|
||||
if (complete) return complete
|
||||
|
||||
const result: Message[] = []
|
||||
|
||||
// Reset regex lastIndex for global pattern
|
||||
COMPLETE_MESSAGE_PATTERN.lastIndex = 0
|
||||
let match
|
||||
while ((match = COMPLETE_MESSAGE_PATTERN.exec(buffer)) !== null) {
|
||||
result.push({ role: match[1] as Message['role'], content: unescapeContent(match[2]) })
|
||||
}
|
||||
|
||||
// Check for incomplete message at end (content still streaming)
|
||||
const lastContentIdx = buffer.lastIndexOf('"content"')
|
||||
if (lastContentIdx !== -1) {
|
||||
const tail = buffer.slice(lastContentIdx)
|
||||
@@ -172,7 +171,6 @@ export function MessagesInput({
|
||||
const roleMatch = head.match(ROLE_BEFORE_CONTENT_PATTERN)
|
||||
if (roleMatch) {
|
||||
const content = unescapeContent(incomplete[1])
|
||||
// Only add if not duplicate of last complete message
|
||||
if (result.length === 0 || result[result.length - 1].content !== content) {
|
||||
result.push({ role: roleMatch[1] as Message['role'], content })
|
||||
}
|
||||
@@ -185,9 +183,6 @@ export function MessagesInput({
|
||||
[parseMessages]
|
||||
)
|
||||
|
||||
/**
|
||||
* Wand hook for AI-assisted content generation
|
||||
*/
|
||||
const wandHook = useWand({
|
||||
wandConfig: config.wandConfig,
|
||||
currentValue: getMessagesJson(),
|
||||
@@ -208,7 +203,6 @@ export function MessagesInput({
|
||||
setLocalMessages(validMessages)
|
||||
setMessages(validMessages)
|
||||
} else {
|
||||
// Fallback: treat as raw system prompt
|
||||
const trimmed = content.trim()
|
||||
if (trimmed) {
|
||||
const fallback: Message[] = [{ role: 'system', content: trimmed }]
|
||||
@@ -219,9 +213,6 @@ export function MessagesInput({
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Expose wand control handlers to parent via ref
|
||||
*/
|
||||
useImperativeHandle(
|
||||
wandControlRef,
|
||||
() => ({
|
||||
@@ -249,9 +240,6 @@ export function MessagesInput({
|
||||
}
|
||||
}, [isPreview, previewValue, messages])
|
||||
|
||||
/**
|
||||
* Gets the current messages array
|
||||
*/
|
||||
const currentMessages = useMemo<Message[]>(() => {
|
||||
if (isPreview && previewValue && Array.isArray(previewValue)) {
|
||||
return previewValue
|
||||
@@ -269,9 +257,6 @@ export function MessagesInput({
|
||||
startHeight: number
|
||||
} | null>(null)
|
||||
|
||||
/**
|
||||
* Updates a specific message's content
|
||||
*/
|
||||
const updateMessageContent = useCallback(
|
||||
(index: number, content: string) => {
|
||||
if (isPreview || disabled) return
|
||||
@@ -287,17 +272,26 @@ export function MessagesInput({
|
||||
[localMessages, setMessages, isPreview, disabled]
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates a specific message's role
|
||||
*/
|
||||
const updateMessageRole = useCallback(
|
||||
(index: number, role: 'system' | 'user' | 'assistant') => {
|
||||
(index: number, role: 'system' | 'user' | 'assistant' | 'media') => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const updatedMessages = [...localMessages]
|
||||
updatedMessages[index] = {
|
||||
...updatedMessages[index],
|
||||
role,
|
||||
if (role === 'media') {
|
||||
updatedMessages[index] = {
|
||||
...updatedMessages[index],
|
||||
role,
|
||||
content: updatedMessages[index].content || '',
|
||||
media: updatedMessages[index].media || {
|
||||
data: '',
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const { media: _, ...rest } = updatedMessages[index]
|
||||
updatedMessages[index] = {
|
||||
...rest,
|
||||
role,
|
||||
}
|
||||
}
|
||||
setLocalMessages(updatedMessages)
|
||||
setMessages(updatedMessages)
|
||||
@@ -305,9 +299,6 @@ export function MessagesInput({
|
||||
[localMessages, setMessages, isPreview, disabled]
|
||||
)
|
||||
|
||||
/**
|
||||
* Adds a message after the specified index
|
||||
*/
|
||||
const addMessageAfter = useCallback(
|
||||
(index: number) => {
|
||||
if (isPreview || disabled) return
|
||||
@@ -320,9 +311,6 @@ export function MessagesInput({
|
||||
[localMessages, setMessages, isPreview, disabled]
|
||||
)
|
||||
|
||||
/**
|
||||
* Deletes a message at the specified index
|
||||
*/
|
||||
const deleteMessage = useCallback(
|
||||
(index: number) => {
|
||||
if (isPreview || disabled) return
|
||||
@@ -335,9 +323,6 @@ export function MessagesInput({
|
||||
[localMessages, setMessages, isPreview, disabled]
|
||||
)
|
||||
|
||||
/**
|
||||
* Moves a message up in the list
|
||||
*/
|
||||
const moveMessageUp = useCallback(
|
||||
(index: number) => {
|
||||
if (isPreview || disabled || index === 0) return
|
||||
@@ -352,9 +337,6 @@ export function MessagesInput({
|
||||
[localMessages, setMessages, isPreview, disabled]
|
||||
)
|
||||
|
||||
/**
|
||||
* Moves a message down in the list
|
||||
*/
|
||||
const moveMessageDown = useCallback(
|
||||
(index: number) => {
|
||||
if (isPreview || disabled || index === localMessages.length - 1) return
|
||||
@@ -369,18 +351,11 @@ export function MessagesInput({
|
||||
[localMessages, setMessages, isPreview, disabled]
|
||||
)
|
||||
|
||||
/**
|
||||
* Capitalizes the first letter of the role
|
||||
*/
|
||||
const formatRole = (role: string): string => {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles header click to focus the textarea
|
||||
*/
|
||||
const handleHeaderClick = useCallback((index: number, e: React.MouseEvent) => {
|
||||
// Don't focus if clicking on interactive elements
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-radix-popper-content-wrapper]')) {
|
||||
return
|
||||
@@ -570,50 +545,52 @@ export function MessagesInput({
|
||||
className='flex cursor-pointer items-center justify-between px-[8px] pt-[6px]'
|
||||
onClick={(e) => handleHeaderClick(index, e)}
|
||||
>
|
||||
<Popover
|
||||
open={openPopoverIndex === index}
|
||||
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
|
||||
(isPreview || disabled) &&
|
||||
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='Select message role'
|
||||
>
|
||||
{formatRole(message.role)}
|
||||
{!isPreview && !disabled && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 flex-shrink-0 transition-transform duration-100',
|
||||
openPopoverIndex === index && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent minWidth={140} align='start'>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{(['system', 'user', 'assistant'] as const).map((role) => (
|
||||
<PopoverItem
|
||||
key={role}
|
||||
active={message.role === role}
|
||||
onClick={() => {
|
||||
updateMessageRole(index, role)
|
||||
setOpenPopoverIndex(null)
|
||||
}}
|
||||
>
|
||||
<span>{formatRole(role)}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className='flex items-center'>
|
||||
<Popover
|
||||
open={openPopoverIndex === index}
|
||||
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
|
||||
(isPreview || disabled) &&
|
||||
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='Select message role'
|
||||
>
|
||||
{formatRole(message.role)}
|
||||
{!isPreview && !disabled && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 flex-shrink-0 transition-transform duration-100',
|
||||
openPopoverIndex === index && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent minWidth={140} align='start'>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{(['system', 'user', 'assistant', 'media'] as const).map((role) => (
|
||||
<PopoverItem
|
||||
key={role}
|
||||
active={message.role === role}
|
||||
onClick={() => {
|
||||
updateMessageRole(index, role)
|
||||
setOpenPopoverIndex(null)
|
||||
}}
|
||||
>
|
||||
<span>{formatRole(role)}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{!isPreview && !disabled && (
|
||||
<div className='flex items-center'>
|
||||
@@ -657,6 +634,43 @@ export function MessagesInput({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Mode toggle for media messages */}
|
||||
{message.role === 'media' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMediaMode((m) => (m === 'basic' ? 'advanced' : 'basic'))
|
||||
}}
|
||||
disabled={disabled}
|
||||
className='-my-1 -mr-1 h-6 w-6 p-0'
|
||||
aria-label={
|
||||
mediaMode === 'advanced'
|
||||
? 'Switch to file upload'
|
||||
: 'Switch to URL/text input'
|
||||
}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
mediaMode === 'advanced'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>
|
||||
{mediaMode === 'advanced'
|
||||
? 'Switch to file upload'
|
||||
: 'Switch to URL/text input'}
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
@@ -673,98 +687,138 @@ export function MessagesInput({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Input with overlay for variable highlighting */}
|
||||
<div className='relative w-full overflow-hidden'>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRefs.current[fieldId] = el
|
||||
}}
|
||||
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
|
||||
placeholder='Enter message content...'
|
||||
value={message.content}
|
||||
onChange={fieldHandlers.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab' && !isPreview && !disabled) {
|
||||
e.preventDefault()
|
||||
const direction = e.shiftKey ? -1 : 1
|
||||
const nextIndex = index + direction
|
||||
|
||||
if (nextIndex >= 0 && nextIndex < currentMessages.length) {
|
||||
const nextFieldId = `message-${nextIndex}`
|
||||
const nextTextarea = textareaRefs.current[nextFieldId]
|
||||
if (nextTextarea) {
|
||||
nextTextarea.focus()
|
||||
nextTextarea.selectionStart = nextTextarea.value.length
|
||||
nextTextarea.selectionEnd = nextTextarea.value.length
|
||||
{/* Content Input - different for media vs text messages */}
|
||||
{message.role === 'media' ? (
|
||||
<div className='relative w-full px-[8px] py-[8px]'>
|
||||
{mediaMode === 'basic' ? (
|
||||
<FileUpload
|
||||
blockId={blockId}
|
||||
subBlockId={`${subBlockId}-media-${index}`}
|
||||
acceptedTypes='image/*,audio/*,video/*,application/pdf,.doc,.docx,.txt'
|
||||
multiple={false}
|
||||
isPreview={isPreview}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRefs.current[fieldId] = el
|
||||
}}
|
||||
className='relative z-[2] m-0 box-border h-auto min-h-[60px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words rounded-[4px] border border-[var(--border-1)] bg-transparent px-[8px] py-[6px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] outline-none transition-colors [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] hover:border-[var(--border-2)] focus:border-[var(--border-2)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
|
||||
placeholder='Enter URL or paste base64 data...'
|
||||
value={message.media?.data || ''}
|
||||
onChange={(e) => {
|
||||
const updatedMessages = [...localMessages]
|
||||
if (updatedMessages[index].role === 'media') {
|
||||
updatedMessages[index] = {
|
||||
...updatedMessages[index],
|
||||
content: e.target.value.substring(0, 50),
|
||||
media: {
|
||||
...updatedMessages[index].media,
|
||||
data: e.target.value,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fieldHandlers.onKeyDown(e)
|
||||
}}
|
||||
onDrop={fieldHandlers.onDrop}
|
||||
onDragOver={fieldHandlers.onDragOver}
|
||||
onFocus={fieldHandlers.onFocus}
|
||||
onScroll={(e) => {
|
||||
const overlay = overlayRefs.current[fieldId]
|
||||
if (overlay) {
|
||||
overlay.scrollTop = e.currentTarget.scrollTop
|
||||
overlay.scrollLeft = e.currentTarget.scrollLeft
|
||||
}
|
||||
}}
|
||||
disabled={isPreview || disabled}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
overlayRefs.current[fieldId] = el
|
||||
}}
|
||||
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
>
|
||||
{formatDisplayText(message.content, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
{message.content.endsWith('\n') && '\u200B'}
|
||||
setLocalMessages(updatedMessages)
|
||||
setMessages(updatedMessages)
|
||||
}}
|
||||
disabled={isPreview || disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Env var dropdown for this message */}
|
||||
<EnvVarDropdown
|
||||
visible={fieldState.showEnvVars && !isPreview && !disabled}
|
||||
onSelect={handleEnvSelect}
|
||||
searchTerm={fieldState.searchTerm}
|
||||
inputValue={message.content}
|
||||
cursorPosition={fieldState.cursorPosition}
|
||||
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
|
||||
workspaceId={subBlockInput.workspaceId}
|
||||
maxHeight='192px'
|
||||
inputRef={textareaRefObject}
|
||||
/>
|
||||
|
||||
{/* Tag dropdown for this message */}
|
||||
<TagDropdown
|
||||
visible={fieldState.showTags && !isPreview && !disabled}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={fieldState.activeSourceBlockId}
|
||||
inputValue={message.content}
|
||||
cursorPosition={fieldState.cursorPosition}
|
||||
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
|
||||
inputRef={textareaRefObject}
|
||||
/>
|
||||
|
||||
{!isPreview && !disabled && (
|
||||
<div
|
||||
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
||||
onMouseDown={(e) => handleResizeStart(fieldId, e)}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault()
|
||||
) : (
|
||||
<div className='relative w-full overflow-hidden'>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRefs.current[fieldId] = el
|
||||
}}
|
||||
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
|
||||
placeholder='Enter message content...'
|
||||
value={message.content}
|
||||
onChange={fieldHandlers.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab' && !isPreview && !disabled) {
|
||||
e.preventDefault()
|
||||
const direction = e.shiftKey ? -1 : 1
|
||||
const nextIndex = index + direction
|
||||
|
||||
if (nextIndex >= 0 && nextIndex < currentMessages.length) {
|
||||
const nextFieldId = `message-${nextIndex}`
|
||||
const nextTextarea = textareaRefs.current[nextFieldId]
|
||||
if (nextTextarea) {
|
||||
nextTextarea.focus()
|
||||
nextTextarea.selectionStart = nextTextarea.value.length
|
||||
nextTextarea.selectionEnd = nextTextarea.value.length
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fieldHandlers.onKeyDown(e)
|
||||
}}
|
||||
onDrop={fieldHandlers.onDrop}
|
||||
onDragOver={fieldHandlers.onDragOver}
|
||||
onFocus={fieldHandlers.onFocus}
|
||||
onScroll={(e) => {
|
||||
const overlay = overlayRefs.current[fieldId]
|
||||
if (overlay) {
|
||||
overlay.scrollTop = e.currentTarget.scrollTop
|
||||
overlay.scrollLeft = e.currentTarget.scrollLeft
|
||||
}
|
||||
}}
|
||||
disabled={isPreview || disabled}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
overlayRefs.current[fieldId] = el
|
||||
}}
|
||||
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
>
|
||||
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
|
||||
{formatDisplayText(message.content, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
{message.content.endsWith('\n') && '\u200B'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Env var dropdown for this message */}
|
||||
<EnvVarDropdown
|
||||
visible={fieldState.showEnvVars && !isPreview && !disabled}
|
||||
onSelect={handleEnvSelect}
|
||||
searchTerm={fieldState.searchTerm}
|
||||
inputValue={message.content}
|
||||
cursorPosition={fieldState.cursorPosition}
|
||||
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
|
||||
workspaceId={subBlockInput.workspaceId}
|
||||
maxHeight='192px'
|
||||
inputRef={textareaRefObject}
|
||||
/>
|
||||
|
||||
{/* Tag dropdown for this message */}
|
||||
<TagDropdown
|
||||
visible={fieldState.showTags && !isPreview && !disabled}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={fieldState.activeSourceBlockId}
|
||||
inputValue={message.content}
|
||||
cursorPosition={fieldState.cursorPosition}
|
||||
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
|
||||
inputRef={textareaRefObject}
|
||||
/>
|
||||
|
||||
{!isPreview && !disabled && (
|
||||
<div
|
||||
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
||||
onMouseDown={(e) => handleResizeStart(fieldId, e)}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -85,7 +85,9 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
|
||||
id: 'messages',
|
||||
title: 'Messages',
|
||||
type: 'messages-input',
|
||||
canonicalParamId: 'messages',
|
||||
placeholder: 'Enter messages...',
|
||||
mode: 'basic',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
@@ -122,6 +124,15 @@ Return ONLY the JSON array.`,
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'messagesRaw',
|
||||
title: 'Messages',
|
||||
type: 'code',
|
||||
canonicalParamId: 'messages',
|
||||
placeholder: '[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]',
|
||||
language: 'json',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
|
||||
@@ -2417,4 +2417,177 @@ describe('EdgeManager', () => {
|
||||
expect(successReady).toContain(targetId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Condition with loop downstream - deactivation propagation', () => {
|
||||
it('should deactivate nodes after loop when condition branch containing loop is deactivated', () => {
|
||||
// Scenario: condition → (if) → sentinel_start → loopBody → sentinel_end → (loop_exit) → after_loop
|
||||
// → (else) → other_branch
|
||||
// When condition takes "else" path, the entire if-branch including nodes after the loop should be deactivated
|
||||
const conditionId = 'condition'
|
||||
const sentinelStartId = 'sentinel-start'
|
||||
const loopBodyId = 'loop-body'
|
||||
const sentinelEndId = 'sentinel-end'
|
||||
const afterLoopId = 'after-loop'
|
||||
const otherBranchId = 'other-branch'
|
||||
|
||||
const conditionNode = createMockNode(conditionId, [
|
||||
{ target: sentinelStartId, sourceHandle: 'condition-if' },
|
||||
{ target: otherBranchId, sourceHandle: 'condition-else' },
|
||||
])
|
||||
|
||||
const sentinelStartNode = createMockNode(
|
||||
sentinelStartId,
|
||||
[{ target: loopBodyId }],
|
||||
[conditionId]
|
||||
)
|
||||
|
||||
const loopBodyNode = createMockNode(
|
||||
loopBodyId,
|
||||
[{ target: sentinelEndId }],
|
||||
[sentinelStartId]
|
||||
)
|
||||
|
||||
const sentinelEndNode = createMockNode(
|
||||
sentinelEndId,
|
||||
[
|
||||
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
|
||||
{ target: afterLoopId, sourceHandle: 'loop_exit' },
|
||||
],
|
||||
[loopBodyId]
|
||||
)
|
||||
|
||||
const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId])
|
||||
const otherBranchNode = createMockNode(otherBranchId, [], [conditionId])
|
||||
|
||||
const nodes = new Map<string, DAGNode>([
|
||||
[conditionId, conditionNode],
|
||||
[sentinelStartId, sentinelStartNode],
|
||||
[loopBodyId, loopBodyNode],
|
||||
[sentinelEndId, sentinelEndNode],
|
||||
[afterLoopId, afterLoopNode],
|
||||
[otherBranchId, otherBranchNode],
|
||||
])
|
||||
|
||||
const dag = createMockDAG(nodes)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
// Condition selects "else" branch, deactivating the "if" branch (which contains the loop)
|
||||
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
|
||||
|
||||
// Only otherBranch should be ready
|
||||
expect(readyNodes).toContain(otherBranchId)
|
||||
expect(readyNodes).not.toContain(sentinelStartId)
|
||||
|
||||
// afterLoop should NOT be ready - its incoming edge from sentinel_end should be deactivated
|
||||
expect(readyNodes).not.toContain(afterLoopId)
|
||||
|
||||
// Verify that countActiveIncomingEdges returns 0 for afterLoop
|
||||
// (meaning the loop_exit edge was properly deactivated)
|
||||
// Note: isNodeReady returns true when all edges are deactivated (no pending deps),
|
||||
// but the node won't be in readyNodes since it wasn't reached via an active path
|
||||
expect(edgeManager.isNodeReady(afterLoopNode)).toBe(true) // All edges deactivated = no blocking deps
|
||||
})
|
||||
|
||||
it('should deactivate nodes after parallel when condition branch containing parallel is deactivated', () => {
|
||||
// Similar scenario with parallel instead of loop
|
||||
const conditionId = 'condition'
|
||||
const parallelStartId = 'parallel-start'
|
||||
const parallelBodyId = 'parallel-body'
|
||||
const parallelEndId = 'parallel-end'
|
||||
const afterParallelId = 'after-parallel'
|
||||
const otherBranchId = 'other-branch'
|
||||
|
||||
const conditionNode = createMockNode(conditionId, [
|
||||
{ target: parallelStartId, sourceHandle: 'condition-if' },
|
||||
{ target: otherBranchId, sourceHandle: 'condition-else' },
|
||||
])
|
||||
|
||||
const parallelStartNode = createMockNode(
|
||||
parallelStartId,
|
||||
[{ target: parallelBodyId }],
|
||||
[conditionId]
|
||||
)
|
||||
|
||||
const parallelBodyNode = createMockNode(
|
||||
parallelBodyId,
|
||||
[{ target: parallelEndId }],
|
||||
[parallelStartId]
|
||||
)
|
||||
|
||||
const parallelEndNode = createMockNode(
|
||||
parallelEndId,
|
||||
[{ target: afterParallelId, sourceHandle: 'parallel_exit' }],
|
||||
[parallelBodyId]
|
||||
)
|
||||
|
||||
const afterParallelNode = createMockNode(afterParallelId, [], [parallelEndId])
|
||||
const otherBranchNode = createMockNode(otherBranchId, [], [conditionId])
|
||||
|
||||
const nodes = new Map<string, DAGNode>([
|
||||
[conditionId, conditionNode],
|
||||
[parallelStartId, parallelStartNode],
|
||||
[parallelBodyId, parallelBodyNode],
|
||||
[parallelEndId, parallelEndNode],
|
||||
[afterParallelId, afterParallelNode],
|
||||
[otherBranchId, otherBranchNode],
|
||||
])
|
||||
|
||||
const dag = createMockDAG(nodes)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
// Condition selects "else" branch
|
||||
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
|
||||
|
||||
expect(readyNodes).toContain(otherBranchId)
|
||||
expect(readyNodes).not.toContain(parallelStartId)
|
||||
expect(readyNodes).not.toContain(afterParallelId)
|
||||
// isNodeReady returns true when all edges are deactivated (no pending deps)
|
||||
expect(edgeManager.isNodeReady(afterParallelNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('should still correctly handle normal loop exit (not deactivate when loop runs)', () => {
|
||||
// When a loop actually executes and exits normally, after_loop should become ready
|
||||
const sentinelStartId = 'sentinel-start'
|
||||
const loopBodyId = 'loop-body'
|
||||
const sentinelEndId = 'sentinel-end'
|
||||
const afterLoopId = 'after-loop'
|
||||
|
||||
const sentinelStartNode = createMockNode(sentinelStartId, [{ target: loopBodyId }])
|
||||
|
||||
const loopBodyNode = createMockNode(
|
||||
loopBodyId,
|
||||
[{ target: sentinelEndId }],
|
||||
[sentinelStartId]
|
||||
)
|
||||
|
||||
const sentinelEndNode = createMockNode(
|
||||
sentinelEndId,
|
||||
[
|
||||
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
|
||||
{ target: afterLoopId, sourceHandle: 'loop_exit' },
|
||||
],
|
||||
[loopBodyId]
|
||||
)
|
||||
|
||||
const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId])
|
||||
|
||||
const nodes = new Map<string, DAGNode>([
|
||||
[sentinelStartId, sentinelStartNode],
|
||||
[loopBodyId, loopBodyNode],
|
||||
[sentinelEndId, sentinelEndNode],
|
||||
[afterLoopId, afterLoopNode],
|
||||
])
|
||||
|
||||
const dag = createMockDAG(nodes)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
|
||||
// Simulate sentinel_end completing with loop_exit (loop is done)
|
||||
const readyNodes = edgeManager.processOutgoingEdges(sentinelEndNode, {
|
||||
selectedRoute: 'loop_exit',
|
||||
})
|
||||
|
||||
// afterLoop should be ready
|
||||
expect(readyNodes).toContain(afterLoopId)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -243,7 +243,7 @@ export class EdgeManager {
|
||||
}
|
||||
|
||||
for (const [, outgoingEdge] of targetNode.outgoingEdges) {
|
||||
if (!this.isControlEdge(outgoingEdge.sourceHandle)) {
|
||||
if (!this.isBackwardsEdge(outgoingEdge.sourceHandle)) {
|
||||
this.deactivateEdgeAndDescendants(
|
||||
targetId,
|
||||
outgoingEdge.target,
|
||||
|
||||
@@ -58,7 +58,12 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
const providerId = getProviderFromModel(model)
|
||||
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
|
||||
const streamingConfig = this.getStreamingConfig(ctx, block)
|
||||
const messages = await this.buildMessages(ctx, filteredInputs)
|
||||
const rawMessages = await this.buildMessages(ctx, filteredInputs)
|
||||
|
||||
// Transform media messages to provider-specific format
|
||||
const messages = rawMessages
|
||||
? this.transformMediaMessages(rawMessages, providerId)
|
||||
: undefined
|
||||
|
||||
const providerRequest = this.buildProviderRequest({
|
||||
ctx,
|
||||
@@ -815,10 +820,248 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
typeof msg === 'object' &&
|
||||
'role' in msg &&
|
||||
'content' in msg &&
|
||||
['system', 'user', 'assistant'].includes(msg.role)
|
||||
['system', 'user', 'assistant', 'media'].includes(msg.role)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms messages with 'media' role into provider-compatible format.
|
||||
* Media messages are merged with the preceding or following user message,
|
||||
* or converted to a user message with multimodal content.
|
||||
*/
|
||||
private transformMediaMessages(messages: Message[], providerId: string): Message[] {
|
||||
const result: Message[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
|
||||
if (msg.role !== 'media') {
|
||||
result.push(msg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Media message - transform based on provider
|
||||
const mediaContent = this.createProviderMediaContent(msg, providerId)
|
||||
if (!mediaContent) {
|
||||
logger.warn('Could not create media content for message', { msg })
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we should merge with the previous user message
|
||||
const lastMessage = result[result.length - 1]
|
||||
if (lastMessage && lastMessage.role === 'user') {
|
||||
// Merge media into the previous user message's content array
|
||||
const existingContent = this.ensureContentArray(lastMessage, providerId)
|
||||
existingContent.push(mediaContent)
|
||||
lastMessage.content = existingContent as any
|
||||
} else {
|
||||
// Create a new user message with the media content
|
||||
result.push({
|
||||
role: 'user',
|
||||
content: [mediaContent] as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Post-process: ensure all user messages have consistent content format
|
||||
return result.map((msg) => {
|
||||
if (msg.role === 'user' && typeof msg.content === 'string') {
|
||||
// Convert string content to provider-specific text format
|
||||
return {
|
||||
...msg,
|
||||
content: this.createTextContent(msg.content, providerId) as any,
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a user message has content as an array for multimodal support
|
||||
*/
|
||||
private ensureContentArray(msg: Message, providerId: string): any[] {
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content
|
||||
}
|
||||
if (typeof msg.content === 'string' && msg.content) {
|
||||
return [this.createTextContent(msg.content, providerId)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates provider-specific text content block
|
||||
*/
|
||||
private createTextContent(text: string, providerId: string): any {
|
||||
switch (providerId) {
|
||||
case 'google':
|
||||
case 'vertex':
|
||||
return { text }
|
||||
case 'anthropic':
|
||||
return { type: 'text', text }
|
||||
default:
|
||||
// OpenAI format (used by most providers)
|
||||
return { type: 'text', text }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates provider-specific media content from a media message
|
||||
*/
|
||||
private createProviderMediaContent(msg: Message, providerId: string): any {
|
||||
const media = msg.media
|
||||
if (!media) return null
|
||||
|
||||
const { sourceType, data, mimeType } = media
|
||||
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
return this.createAnthropicMediaContent(sourceType, data, mimeType)
|
||||
|
||||
case 'google':
|
||||
case 'vertex':
|
||||
return this.createGeminiMediaContent(sourceType, data, mimeType)
|
||||
|
||||
default:
|
||||
// OpenAI format (used by OpenAI, Azure, xAI, Mistral, etc.)
|
||||
return this.createOpenAIMediaContent(sourceType, data, mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates OpenAI-compatible media content
|
||||
*/
|
||||
private createOpenAIMediaContent(
|
||||
sourceType: string,
|
||||
data: string,
|
||||
mimeType?: string
|
||||
): any {
|
||||
const isImage = mimeType?.startsWith('image/')
|
||||
const isAudio = mimeType?.startsWith('audio/')
|
||||
|
||||
if (isImage) {
|
||||
if (sourceType === 'url') {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: { url: data, detail: 'auto' },
|
||||
}
|
||||
}
|
||||
// base64 or file (already converted to base64)
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: { url: data, detail: 'auto' },
|
||||
}
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
const base64Data = data.includes(',') ? data.split(',')[1] : data
|
||||
return {
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: base64Data,
|
||||
format: mimeType === 'audio/wav' ? 'wav' : 'mp3',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// For documents/files, include as URL
|
||||
if (sourceType === 'url') {
|
||||
return {
|
||||
type: 'file',
|
||||
file: { url: data },
|
||||
}
|
||||
}
|
||||
|
||||
// Base64 file - some providers may not support this directly
|
||||
logger.warn('Base64 file content may not be supported by this provider')
|
||||
return {
|
||||
type: 'text',
|
||||
text: `[File: ${mimeType || 'unknown type'}]`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Anthropic-compatible media content
|
||||
*/
|
||||
private createAnthropicMediaContent(
|
||||
sourceType: string,
|
||||
data: string,
|
||||
mimeType?: string
|
||||
): any {
|
||||
const isImage = mimeType?.startsWith('image/')
|
||||
const isPdf = mimeType === 'application/pdf'
|
||||
|
||||
if (isImage) {
|
||||
if (sourceType === 'url') {
|
||||
return {
|
||||
type: 'image',
|
||||
source: { type: 'url', url: data },
|
||||
}
|
||||
}
|
||||
// base64
|
||||
const base64Data = data.includes(',') ? data.split(',')[1] : data
|
||||
return {
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mimeType || 'image/png',
|
||||
data: base64Data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
if (sourceType === 'url') {
|
||||
return {
|
||||
type: 'document',
|
||||
source: { type: 'url', url: data },
|
||||
}
|
||||
}
|
||||
const base64Data = data.includes(',') ? data.split(',')[1] : data
|
||||
return {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: base64Data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other types
|
||||
return {
|
||||
type: 'text',
|
||||
text: `[File: ${mimeType || 'unknown type'}]`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Google Gemini-compatible media content
|
||||
*/
|
||||
private createGeminiMediaContent(
|
||||
sourceType: string,
|
||||
data: string,
|
||||
mimeType?: string
|
||||
): any {
|
||||
if (sourceType === 'url') {
|
||||
return {
|
||||
fileData: {
|
||||
mimeType: mimeType || 'application/octet-stream',
|
||||
fileUri: data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// base64
|
||||
const base64Data = data.includes(',') ? data.split(',')[1] : data
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: mimeType || 'application/octet-stream',
|
||||
data: base64Data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private processMemories(memories: any): Message[] {
|
||||
if (!memories) return []
|
||||
|
||||
|
||||
@@ -42,9 +42,25 @@ export interface ToolInput {
|
||||
customToolId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Media content for multimodal messages
|
||||
*/
|
||||
export interface MediaContent {
|
||||
/** Mode: basic (file upload) or advanced (URL/base64 text input) */
|
||||
mode: 'basic' | 'advanced'
|
||||
/** The URL or base64 data */
|
||||
data: string
|
||||
/** MIME type (e.g., 'image/png', 'application/pdf', 'audio/mp3') */
|
||||
mimeType?: string
|
||||
/** Optional filename for file uploads */
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
role: 'system' | 'user' | 'assistant' | 'media'
|
||||
content: string
|
||||
/** Media content for 'media' role messages */
|
||||
media?: MediaContent
|
||||
executionId?: string
|
||||
function_call?: any
|
||||
tool_calls?: any[]
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type KnowledgeBaseArgs,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
/**
|
||||
* Client tool for knowledge base operations
|
||||
@@ -102,7 +103,19 @@ export class KnowledgeBaseClientTool extends BaseClientTool {
|
||||
const logger = createLogger('KnowledgeBaseClientTool')
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
const payload: KnowledgeBaseArgs = { ...(args || { operation: 'list' }) }
|
||||
|
||||
// Get the workspace ID from the workflow registry hydration state
|
||||
const { hydration } = useWorkflowRegistry.getState()
|
||||
const workspaceId = hydration.workspaceId
|
||||
|
||||
// Build payload with workspace ID included in args
|
||||
const payload: KnowledgeBaseArgs = {
|
||||
...(args || { operation: 'list' }),
|
||||
args: {
|
||||
...(args?.args || {}),
|
||||
workspaceId: workspaceId || undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -2508,6 +2508,10 @@ async function validateWorkflowSelectorIds(
|
||||
for (const subBlockConfig of blockConfig.subBlocks) {
|
||||
if (!SELECTOR_TYPES.has(subBlockConfig.type)) continue
|
||||
|
||||
// Skip oauth-input - credentials are pre-validated before edit application
|
||||
// This allows existing collaborator credentials to remain untouched
|
||||
if (subBlockConfig.type === 'oauth-input') continue
|
||||
|
||||
const subBlockValue = blockData.subBlocks?.[subBlockConfig.id]?.value
|
||||
if (!subBlockValue) continue
|
||||
|
||||
@@ -2573,6 +2577,295 @@ async function validateWorkflowSelectorIds(
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-validates credential and apiKey inputs in operations before they are applied.
|
||||
* - Validates oauth-input (credential) IDs belong to the user
|
||||
* - Filters out apiKey inputs for hosted models when isHosted is true
|
||||
* - Also validates credentials and apiKeys in nestedNodes (blocks inside loop/parallel)
|
||||
* Returns validation errors for any removed inputs.
|
||||
*/
|
||||
async function preValidateCredentialInputs(
|
||||
operations: EditWorkflowOperation[],
|
||||
context: { userId: string },
|
||||
workflowState?: Record<string, unknown>
|
||||
): Promise<{ filteredOperations: EditWorkflowOperation[]; errors: ValidationError[] }> {
|
||||
const { isHosted } = await import('@/lib/core/config/feature-flags')
|
||||
const { getHostedModels } = await import('@/providers/utils')
|
||||
|
||||
const logger = createLogger('PreValidateCredentials')
|
||||
const errors: ValidationError[] = []
|
||||
|
||||
// Collect credential and apiKey inputs that need validation/filtering
|
||||
const credentialInputs: Array<{
|
||||
operationIndex: number
|
||||
blockId: string
|
||||
blockType: string
|
||||
fieldName: string
|
||||
value: string
|
||||
nestedBlockId?: string
|
||||
}> = []
|
||||
|
||||
const hostedApiKeyInputs: Array<{
|
||||
operationIndex: number
|
||||
blockId: string
|
||||
blockType: string
|
||||
model: string
|
||||
nestedBlockId?: string
|
||||
}> = []
|
||||
|
||||
const hostedModelsLower = isHosted ? new Set(getHostedModels().map((m) => m.toLowerCase())) : null
|
||||
|
||||
/**
|
||||
* Collect credential inputs from a block's inputs based on its block config
|
||||
*/
|
||||
function collectCredentialInputs(
|
||||
blockConfig: ReturnType<typeof getBlock>,
|
||||
inputs: Record<string, unknown>,
|
||||
opIndex: number,
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
nestedBlockId?: string
|
||||
) {
|
||||
if (!blockConfig) return
|
||||
|
||||
for (const subBlockConfig of blockConfig.subBlocks) {
|
||||
if (subBlockConfig.type !== 'oauth-input') continue
|
||||
|
||||
const inputValue = inputs[subBlockConfig.id]
|
||||
if (!inputValue || typeof inputValue !== 'string' || inputValue.trim() === '') continue
|
||||
|
||||
credentialInputs.push({
|
||||
operationIndex: opIndex,
|
||||
blockId,
|
||||
blockType,
|
||||
fieldName: subBlockConfig.id,
|
||||
value: inputValue,
|
||||
nestedBlockId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if apiKey should be filtered for a block with the given model
|
||||
*/
|
||||
function collectHostedApiKeyInput(
|
||||
inputs: Record<string, unknown>,
|
||||
modelValue: string | undefined,
|
||||
opIndex: number,
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
nestedBlockId?: string
|
||||
) {
|
||||
if (!hostedModelsLower || !inputs.apiKey) return
|
||||
if (!modelValue || typeof modelValue !== 'string') return
|
||||
|
||||
if (hostedModelsLower.has(modelValue.toLowerCase())) {
|
||||
hostedApiKeyInputs.push({
|
||||
operationIndex: opIndex,
|
||||
blockId,
|
||||
blockType,
|
||||
model: modelValue,
|
||||
nestedBlockId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
operations.forEach((op, opIndex) => {
|
||||
// Process main block inputs
|
||||
if (op.params?.inputs && op.params?.type) {
|
||||
const blockConfig = getBlock(op.params.type)
|
||||
if (blockConfig) {
|
||||
// Collect credentials from main block
|
||||
collectCredentialInputs(
|
||||
blockConfig,
|
||||
op.params.inputs as Record<string, unknown>,
|
||||
opIndex,
|
||||
op.block_id,
|
||||
op.params.type
|
||||
)
|
||||
|
||||
// Check for apiKey inputs on hosted models
|
||||
let modelValue = (op.params.inputs as Record<string, unknown>).model as string | undefined
|
||||
|
||||
// For edit operations, if model is not being changed, check existing block's model
|
||||
if (
|
||||
!modelValue &&
|
||||
op.operation_type === 'edit' &&
|
||||
(op.params.inputs as Record<string, unknown>).apiKey &&
|
||||
workflowState
|
||||
) {
|
||||
const existingBlock = (workflowState.blocks as Record<string, unknown>)?.[op.block_id] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const existingSubBlocks = existingBlock?.subBlocks as Record<string, unknown> | undefined
|
||||
const existingModelSubBlock = existingSubBlocks?.model as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
modelValue = existingModelSubBlock?.value as string | undefined
|
||||
}
|
||||
|
||||
collectHostedApiKeyInput(
|
||||
op.params.inputs as Record<string, unknown>,
|
||||
modelValue,
|
||||
opIndex,
|
||||
op.block_id,
|
||||
op.params.type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested nodes (blocks inside loop/parallel containers)
|
||||
const nestedNodes = op.params?.nestedNodes as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined
|
||||
if (nestedNodes) {
|
||||
Object.entries(nestedNodes).forEach(([childId, childBlock]) => {
|
||||
const childType = childBlock.type as string | undefined
|
||||
const childInputs = childBlock.inputs as Record<string, unknown> | undefined
|
||||
if (!childType || !childInputs) return
|
||||
|
||||
const childBlockConfig = getBlock(childType)
|
||||
if (!childBlockConfig) return
|
||||
|
||||
// Collect credentials from nested block
|
||||
collectCredentialInputs(
|
||||
childBlockConfig,
|
||||
childInputs,
|
||||
opIndex,
|
||||
op.block_id,
|
||||
childType,
|
||||
childId
|
||||
)
|
||||
|
||||
// Check for apiKey inputs on hosted models in nested block
|
||||
const modelValue = childInputs.model as string | undefined
|
||||
collectHostedApiKeyInput(childInputs, modelValue, opIndex, op.block_id, childType, childId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const hasCredentialsToValidate = credentialInputs.length > 0
|
||||
const hasHostedApiKeysToFilter = hostedApiKeyInputs.length > 0
|
||||
|
||||
if (!hasCredentialsToValidate && !hasHostedApiKeysToFilter) {
|
||||
return { filteredOperations: operations, errors }
|
||||
}
|
||||
|
||||
// Deep clone operations so we can modify them
|
||||
const filteredOperations = structuredClone(operations)
|
||||
|
||||
// Filter out apiKey inputs for hosted models and add validation errors
|
||||
if (hasHostedApiKeysToFilter) {
|
||||
logger.info('Filtering apiKey inputs for hosted models', { count: hostedApiKeyInputs.length })
|
||||
|
||||
for (const apiKeyInput of hostedApiKeyInputs) {
|
||||
const op = filteredOperations[apiKeyInput.operationIndex]
|
||||
|
||||
// Handle nested block apiKey filtering
|
||||
if (apiKeyInput.nestedBlockId) {
|
||||
const nestedNodes = op.params?.nestedNodes as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined
|
||||
const nestedBlock = nestedNodes?.[apiKeyInput.nestedBlockId]
|
||||
const nestedInputs = nestedBlock?.inputs as Record<string, unknown> | undefined
|
||||
if (nestedInputs?.apiKey) {
|
||||
nestedInputs.apiKey = undefined
|
||||
logger.debug('Filtered apiKey for hosted model in nested block', {
|
||||
parentBlockId: apiKeyInput.blockId,
|
||||
nestedBlockId: apiKeyInput.nestedBlockId,
|
||||
model: apiKeyInput.model,
|
||||
})
|
||||
|
||||
errors.push({
|
||||
blockId: apiKeyInput.nestedBlockId,
|
||||
blockType: apiKeyInput.blockType,
|
||||
field: 'apiKey',
|
||||
value: '[redacted]',
|
||||
error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`,
|
||||
})
|
||||
}
|
||||
} else if (op.params?.inputs?.apiKey) {
|
||||
// Handle main block apiKey filtering
|
||||
op.params.inputs.apiKey = undefined
|
||||
logger.debug('Filtered apiKey for hosted model', {
|
||||
blockId: apiKeyInput.blockId,
|
||||
model: apiKeyInput.model,
|
||||
})
|
||||
|
||||
errors.push({
|
||||
blockId: apiKeyInput.blockId,
|
||||
blockType: apiKeyInput.blockType,
|
||||
field: 'apiKey',
|
||||
value: '[redacted]',
|
||||
error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate credential inputs
|
||||
if (hasCredentialsToValidate) {
|
||||
logger.info('Pre-validating credential inputs', {
|
||||
credentialCount: credentialInputs.length,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
const allCredentialIds = credentialInputs.map((c) => c.value)
|
||||
const validationResult = await validateSelectorIds('oauth-input', allCredentialIds, context)
|
||||
const invalidSet = new Set(validationResult.invalid)
|
||||
|
||||
if (invalidSet.size > 0) {
|
||||
for (const credInput of credentialInputs) {
|
||||
if (!invalidSet.has(credInput.value)) continue
|
||||
|
||||
const op = filteredOperations[credInput.operationIndex]
|
||||
|
||||
// Handle nested block credential removal
|
||||
if (credInput.nestedBlockId) {
|
||||
const nestedNodes = op.params?.nestedNodes as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined
|
||||
const nestedBlock = nestedNodes?.[credInput.nestedBlockId]
|
||||
const nestedInputs = nestedBlock?.inputs as Record<string, unknown> | undefined
|
||||
if (nestedInputs?.[credInput.fieldName]) {
|
||||
delete nestedInputs[credInput.fieldName]
|
||||
logger.info('Removed invalid credential from nested block', {
|
||||
parentBlockId: credInput.blockId,
|
||||
nestedBlockId: credInput.nestedBlockId,
|
||||
field: credInput.fieldName,
|
||||
invalidValue: credInput.value,
|
||||
})
|
||||
}
|
||||
} else if (op.params?.inputs?.[credInput.fieldName]) {
|
||||
// Handle main block credential removal
|
||||
delete op.params.inputs[credInput.fieldName]
|
||||
logger.info('Removed invalid credential from operation', {
|
||||
blockId: credInput.blockId,
|
||||
field: credInput.fieldName,
|
||||
invalidValue: credInput.value,
|
||||
})
|
||||
}
|
||||
|
||||
const warningInfo = validationResult.warning ? `. ${validationResult.warning}` : ''
|
||||
const errorBlockId = credInput.nestedBlockId ?? credInput.blockId
|
||||
errors.push({
|
||||
blockId: errorBlockId,
|
||||
blockType: credInput.blockType,
|
||||
field: credInput.fieldName,
|
||||
value: credInput.value,
|
||||
error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`,
|
||||
})
|
||||
}
|
||||
|
||||
logger.warn('Filtered out invalid credentials', {
|
||||
invalidCount: invalidSet.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { filteredOperations, errors }
|
||||
}
|
||||
|
||||
async function getCurrentWorkflowStateFromDb(
|
||||
workflowId: string
|
||||
): Promise<{ workflowState: any; subBlockValues: Record<string, Record<string, any>> }> {
|
||||
@@ -2657,12 +2950,29 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
|
||||
// Get permission config for the user
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
|
||||
// Pre-validate credential and apiKey inputs before applying operations
|
||||
// This filters out invalid credentials and apiKeys for hosted models
|
||||
let operationsToApply = operations
|
||||
const credentialErrors: ValidationError[] = []
|
||||
if (context?.userId) {
|
||||
const { filteredOperations, errors: credErrors } = await preValidateCredentialInputs(
|
||||
operations,
|
||||
{ userId: context.userId },
|
||||
workflowState
|
||||
)
|
||||
operationsToApply = filteredOperations
|
||||
credentialErrors.push(...credErrors)
|
||||
}
|
||||
|
||||
// Apply operations directly to the workflow state
|
||||
const {
|
||||
state: modifiedWorkflowState,
|
||||
validationErrors,
|
||||
skippedItems,
|
||||
} = applyOperationsToWorkflowState(workflowState, operations, permissionConfig)
|
||||
} = applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig)
|
||||
|
||||
// Add credential validation errors
|
||||
validationErrors.push(...credentialErrors)
|
||||
|
||||
// Get workspaceId for selector validation
|
||||
let workspaceId: string | undefined
|
||||
|
||||
@@ -111,9 +111,25 @@ export interface ProviderToolConfig {
|
||||
usageControl?: ToolUsageControl
|
||||
}
|
||||
|
||||
/**
|
||||
* Media content for multimodal messages
|
||||
*/
|
||||
export interface MediaContent {
|
||||
/** Mode: basic (file upload) or advanced (URL/base64 text input) */
|
||||
mode: 'basic' | 'advanced'
|
||||
/** The URL or base64 data */
|
||||
data: string
|
||||
/** MIME type (e.g., 'image/png', 'application/pdf', 'audio/mp3') */
|
||||
mimeType?: string
|
||||
/** Optional filename for file uploads */
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant' | 'function' | 'tool'
|
||||
role: 'system' | 'user' | 'assistant' | 'function' | 'tool' | 'media'
|
||||
content: string | null
|
||||
/** Media content for 'media' role messages */
|
||||
media?: MediaContent
|
||||
name?: string
|
||||
function_call?: {
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user