Compare commits

..

2 Commits

Author SHA1 Message Date
Siddharth Ganesan
59cb2f0d2d Copilot enterprise models 2026-02-10 10:49:27 -08:00
Waleed
e321f883b0 improvement(preview): added trigger mode context for deploy preview (#3177)
* improvement(preview): added trigger mode context for deploy preview

* use existing helper

* enhance disabled mode for subblocks

* update

* update all subblocks to allow scrolling in read only mode

* updated short and long input to match others, reverted triggerutils change
2026-02-09 20:32:30 -08:00
38 changed files with 1055 additions and 597 deletions

View File

@@ -10,7 +10,7 @@ import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import { generateChatTitle } from '@/lib/copilot/chat-title'
import { getCopilotModel } from '@/lib/copilot/config'
import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import {
createStreamEventWriter,
@@ -43,7 +43,7 @@ const ChatMessageSchema = z.object({
chatId: z.string().optional(),
workflowId: z.string().optional(),
workflowName: z.string().optional(),
model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.6-opus'),
model: z.string().optional().default('claude-4.6-opus'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),

View File

@@ -0,0 +1,68 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
import type { AvailableModel } from '@/lib/copilot/types'
const logger = createLogger('CopilotModelsAPI')
export async function GET(_req: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
try {
const response = await fetch(`${SIM_AGENT_API_URL}/api/get-available-models`, {
method: 'GET',
headers,
cache: 'no-store',
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
logger.warn('Failed to fetch available models from copilot backend', {
status: response.status,
})
return NextResponse.json(
{
success: false,
error: payload?.error || 'Failed to fetch available models',
models: [],
},
{ status: response.status }
)
}
const rawModels = Array.isArray(payload?.models) ? payload.models : []
const models: AvailableModel[] = rawModels
.filter((item: any) => item && typeof item.id === 'string')
.map((item: any) => ({
id: item.id,
friendlyName: item.friendlyName || item.displayName || item.id,
provider: item.provider || 'unknown',
}))
return NextResponse.json({ success: true, models })
} catch (error) {
logger.error('Error fetching available models', {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{
success: false,
error: 'Failed to fetch available models',
models: [],
},
{ status: 500 }
)
}
}

View File

@@ -4,8 +4,11 @@ import type React from 'react'
import { useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { getToolOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -121,26 +124,41 @@ export function OutputSelect({
: `block-${block.id}`
const blockConfig = getBlock(block.type)
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const effectiveTriggerMode = Boolean(block.triggerMode && isTriggerCapable)
const responseFormatValue =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
let outputsToProcess: Record<string, unknown> = {}
const rawSubBlockValues =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks
: subBlockValues?.[block.id]
const subBlocks: Record<string, { value: unknown }> = {}
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
for (const [key, val] of Object.entries(rawSubBlockValues)) {
// Handle both { value: ... } and raw value formats
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
}
}
outputsToProcess = getEffectiveBlockOutputs(block.type, subBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
}) as Record<string, unknown>
if (responseFormat) {
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {
schemaFields.forEach((field) => {
outputsToProcess[field.name] = { type: field.type }
})
} else {
outputsToProcess = blockConfig?.outputs || {}
}
} else {
// Build subBlocks object for tool selector
const rawSubBlockValues =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks
: subBlockValues?.[block.id]
const subBlocks: Record<string, { value: unknown }> = {}
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
for (const [key, val] of Object.entries(rawSubBlockValues)) {
// Handle both { value: ... } and raw value formats
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
}
}
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, subBlocks) : {}
outputsToProcess =
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
}
if (Object.keys(outputsToProcess).length === 0) return

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Popover,
@@ -9,8 +9,14 @@ import {
PopoverItem,
PopoverScrollArea,
} from '@/components/emcn'
import { getProviderIcon } from '@/providers/utils'
import { MODEL_OPTIONS } from '../../constants'
import {
AnthropicIcon,
AzureIcon,
BedrockIcon,
GeminiIcon,
OpenAIIcon,
} from '@/components/icons'
import { useCopilotStore } from '@/stores/panel'
interface ModelSelectorProps {
/** Currently selected model */
@@ -22,14 +28,22 @@ interface ModelSelectorProps {
}
/**
* Gets the appropriate icon component for a model
* Map a provider string (from the available-models API) to its icon component.
* Falls back to null when the provider is unrecognised.
*/
function getModelIconComponent(modelValue: string) {
const IconComponent = getProviderIcon(modelValue)
if (!IconComponent) {
return null
}
return <IconComponent className='h-3.5 w-3.5' />
const PROVIDER_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
anthropic: AnthropicIcon,
openai: OpenAIIcon,
gemini: GeminiIcon,
google: GeminiIcon,
bedrock: BedrockIcon,
azure: AzureIcon,
'azure-openai': AzureIcon,
'azure-anthropic': AzureIcon,
}
function getIconForProvider(provider: string): React.ComponentType<{ className?: string }> | null {
return PROVIDER_ICON_MAP[provider] ?? null
}
/**
@@ -43,17 +57,31 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const availableModels = useCopilotStore((state) => state.availableModels)
const modelOptions = useMemo(() => {
return availableModels.map((model) => ({
value: model.id,
label: model.friendlyName || model.id,
provider: model.provider,
}))
}, [availableModels])
/** Look up the provider for a model id from the available-models list */
const getProviderForModel = (modelId: string): string | undefined => {
return availableModels.find((m) => m.id === modelId)?.provider
}
const getCollapsedModeLabel = () => {
const model = MODEL_OPTIONS.find((m) => m.value === selectedModel)
return model ? model.label : 'claude-4.5-sonnet'
const model = modelOptions.find((m) => m.value === selectedModel)
return model?.label || selectedModel || 'No models available'
}
const getModelIcon = () => {
const IconComponent = getProviderIcon(selectedModel)
if (!IconComponent) {
return null
}
const provider = getProviderForModel(selectedModel)
if (!provider) return null
const IconComponent = getIconForProvider(provider)
if (!IconComponent) return null
return (
<span className='flex-shrink-0'>
<IconComponent className='h-3 w-3' />
@@ -61,6 +89,14 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
)
}
const getModelIconComponent = (modelValue: string) => {
const provider = getProviderForModel(modelValue)
if (!provider) return null
const IconComponent = getIconForProvider(provider)
if (!IconComponent) return null
return <IconComponent className='h-3.5 w-3.5' />
}
const handleSelect = (modelValue: string) => {
onModelSelect(modelValue)
setOpen(false)
@@ -124,16 +160,20 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverScrollArea className='space-y-[2px]'>
{MODEL_OPTIONS.map((option) => (
<PopoverItem
key={option.value}
active={selectedModel === option.value}
onClick={() => handleSelect(option.value)}
>
{getModelIconComponent(option.value)}
<span>{option.label}</span>
</PopoverItem>
))}
{modelOptions.length > 0 ? (
modelOptions.map((option) => (
<PopoverItem
key={option.value}
active={selectedModel === option.value}
onClick={() => handleSelect(option.value)}
>
{getModelIconComponent(option.value)}
<span>{option.label}</span>
</PopoverItem>
))
) : (
<div className='px-2 py-2 text-xs text-[var(--text-muted)]'>No models available</div>
)}
</PopoverScrollArea>
</PopoverContent>
</Popover>

View File

@@ -242,19 +242,6 @@ export function getCommandDisplayLabel(commandId: string): string {
return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1)
}
/**
* Model configuration options
*/
export const MODEL_OPTIONS = [
{ value: 'claude-4.6-opus', label: 'Claude 4.6 Opus' },
{ value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' },
{ value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' },
{ value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' },
{ value: 'gpt-5.2-codex', label: 'GPT 5.2 Codex' },
{ value: 'gpt-5.2-pro', label: 'GPT 5.2 Pro' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
] as const
/**
* Threshold for considering input "near top" of viewport (in pixels)
*/

View File

@@ -112,6 +112,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
closePlanTodos,
clearPlanArtifact,
savePlanArtifact,
loadAvailableModels,
loadAutoAllowedTools,
resumeActiveStream,
} = useCopilotStore()
@@ -123,6 +124,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
loadAvailableModels,
loadAutoAllowedTools,
currentChat,
isSendingMessage,

View File

@@ -11,6 +11,7 @@ interface UseCopilotInitializationProps {
chatsLoadedForWorkflow: string | null
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
loadChats: (forceRefresh?: boolean) => Promise<void>
loadAvailableModels: () => Promise<void>
loadAutoAllowedTools: () => Promise<void>
currentChat: any
isSendingMessage: boolean
@@ -30,6 +31,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
loadAvailableModels,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
@@ -129,6 +131,17 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
}
}, [loadAutoAllowedTools])
/** Load available models once on mount */
const hasLoadedModelsRef = useRef(false)
useEffect(() => {
if (!hasLoadedModelsRef.current) {
hasLoadedModelsRef.current = true
loadAvailableModels().catch((err) => {
logger.warn('[Copilot] Failed to load available models', err)
})
}
}, [loadAvailableModels])
return {
isInitialized,
}

View File

@@ -61,6 +61,8 @@ function ConnectionItem({
blockId: connection.id,
blockType: connection.type,
mergedSubBlocks,
responseFormat: connection.responseFormat,
operation: connection.operation,
triggerMode: sourceBlock?.triggerMode,
})
const hasFields = fields.length > 0

View File

@@ -324,7 +324,10 @@ export function DocumentTagEntry({
ref={(el) => {
if (el) overlayRefs.current[cellKey] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
!isReadOnly && 'pointer-events-none'
)}
>
<div className='w-full whitespace-pre' style={{ minWidth: 'fit-content' }}>
{formatDisplayText(

View File

@@ -226,7 +226,10 @@ export function EvalInput({
ref={(el) => {
if (el) descriptionOverlayRefs.current[metric.id] = el
}}
className='pointer-events-none absolute inset-0 overflow-auto bg-transparent px-[8px] py-[8px] font-medium font-sans text-[#eeeeee] text-sm'
className={cn(
'absolute inset-0 overflow-auto bg-transparent px-[8px] py-[8px] font-medium font-sans text-[#eeeeee] text-sm',
!(isPreview || disabled) && 'pointer-events-none'
)}
>
<div className='whitespace-pre-wrap'>
{formatDisplayText(metric.description || '', {

View File

@@ -273,7 +273,10 @@ function InputMappingField({
ref={(el) => {
if (el) overlayRefs.current.set(fieldId, el)
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
!disabled && 'pointer-events-none'
)}
style={{ overflowX: 'auto' }}
>
<div

View File

@@ -312,7 +312,10 @@ export function KnowledgeTagFilters({
ref={(el) => {
if (el) overlayRefs.current[cellKey] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
!isReadOnly && 'pointer-events-none'
)}
>
<div className='w-full whitespace-pre' style={{ minWidth: 'fit-content' }}>
{formatDisplayText(

View File

@@ -353,8 +353,9 @@ export function LongInput({
<div
ref={overlayRef}
className={cn(
'pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm',
(isPreview || disabled) && 'opacity-50'
'absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm',
(isPreview || disabled) && 'opacity-50',
!(isPreview || disabled) && 'pointer-events-none'
)}
style={{
fontFamily: 'inherit',

View File

@@ -719,7 +719,10 @@ export function MessagesInput({
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'
className={cn(
'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',
!(isPreview || disabled) && 'pointer-events-none'
)}
>
{formatDisplayText(message.content, {
accessiblePrefixes,

View File

@@ -372,8 +372,9 @@ export const ShortInput = memo(function ShortInput({
<div
ref={overlayRef}
className={cn(
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
(isPreview || disabled) && 'opacity-50'
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
(isPreview || disabled) && 'opacity-50',
!(isPreview || disabled) && 'pointer-events-none'
)}
>
<div className='min-w-fit whitespace-pre'>{formattedText}</div>

View File

@@ -269,7 +269,10 @@ export function FieldFormat({
ref={(el) => {
if (el) nameOverlayRefs.current[field.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
!isReadOnly && 'pointer-events-none'
)}
style={{ scrollbarWidth: 'none' }}
>
<div
@@ -514,7 +517,10 @@ export function FieldFormat({
ref={(el) => {
if (el) overlayRefs.current[field.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
!isReadOnly && 'pointer-events-none'
)}
style={{ scrollbarWidth: 'none' }}
>
<div

View File

@@ -14,11 +14,16 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
getEffectiveBlockOutputPaths,
getEffectiveBlockOutputType,
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import {
getBlockOutputPaths,
getBlockOutputType,
getOutputPathsFromSchema,
getToolOutputPaths,
getToolOutputType,
} from '@/lib/workflows/blocks/block-outputs'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler'
import type {
@@ -209,19 +214,43 @@ const getOutputTypeForPath = (
outputPath: string,
mergedSubBlocksOverride?: Record<string, any>
): string => {
if (block?.type === 'variables') {
return 'any'
if (block?.triggerMode && blockConfig?.triggers?.enabled) {
return getBlockOutputType(block.type, outputPath, mergedSubBlocksOverride, true)
}
if (block?.type === 'starter') {
const startWorkflowValue =
mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow')
if (startWorkflowValue === 'chat') {
const chatModeTypes: Record<string, string> = {
input: 'string',
conversationId: 'string',
files: 'file[]',
}
return chatModeTypes[outputPath] || 'any'
}
const inputFormatValue =
mergedSubBlocksOverride?.inputFormat?.value ?? getSubBlockValue(blockId, 'inputFormat')
if (inputFormatValue && Array.isArray(inputFormatValue)) {
const field = inputFormatValue.find(
(f: { name?: string; type?: string }) => f.name === outputPath
)
if (field?.type) return field.type
}
} else if (blockConfig?.category === 'triggers') {
const blockState = useWorkflowStore.getState().blocks[blockId]
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
return getBlockOutputType(block.type, outputPath, subBlocks)
} else if (blockConfig?.tools?.config?.tool) {
const blockState = useWorkflowStore.getState().blocks[blockId]
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
return getToolOutputType(blockConfig, subBlocks, outputPath)
}
const subBlocks =
mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const triggerMode = Boolean(block?.triggerMode && isTriggerCapable)
return getEffectiveBlockOutputType(block?.type ?? '', outputPath, subBlocks, {
triggerMode,
preferToolOutputs: !triggerMode,
})
const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled
return getBlockOutputType(block?.type ?? '', outputPath, subBlocks, triggerMode)
}
/**
@@ -1059,9 +1088,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const normalizedBlockName = normalizeName(blockName)
const mergedSubBlocks = getMergedSubBlocks(activeSourceBlockId)
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
const responseFormat = parseResponseFormatSafely(responseFormatValue, activeSourceBlockId)
let blockTags: string[]
if (sourceBlock.type === 'variables') {
if (sourceBlock.type === 'evaluator') {
const metricsValue = getSubBlockValue(activeSourceBlockId, 'metrics')
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
blockTags = validMetrics.map(
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
)
} else {
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (sourceBlock.type === 'variables') {
const variablesValue = getSubBlockValue(activeSourceBlockId, 'variables')
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
@@ -1075,24 +1119,106 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
} else {
blockTags = [normalizedBlockName]
}
} else {
const sourceBlockConfig = getBlock(sourceBlock.type)
const isTriggerCapable = sourceBlockConfig ? hasTriggerCapability(sourceBlockConfig) : false
const effectiveTriggerMode = Boolean(sourceBlock.triggerMode && isTriggerCapable)
const outputPaths = getEffectiveBlockOutputPaths(sourceBlock.type, mergedSubBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
if (sourceBlock.type === 'human_in_the_loop' && activeSourceBlockId === blockId) {
blockTags = allTags.filter(
(tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')
)
} else if (allTags.length === 0) {
blockTags = [normalizedBlockName]
} else if (responseFormat) {
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {
blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`)
} else {
blockTags = allTags
const outputPaths = getBlockOutputPaths(
sourceBlock.type,
mergedSubBlocks,
sourceBlock.triggerMode
)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) {
if (sourceBlock.type === 'starter') {
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
if (startWorkflowValue === 'chat') {
blockTags = [
`${normalizedBlockName}.input`,
`${normalizedBlockName}.conversationId`,
`${normalizedBlockName}.files`,
]
} else {
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
if (
inputFormatValue &&
Array.isArray(inputFormatValue) &&
inputFormatValue.length > 0
) {
blockTags = inputFormatValue
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
} else {
blockTags = [normalizedBlockName]
}
}
} else if (sourceBlock.type === 'api_trigger' || sourceBlock.type === 'input_trigger') {
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
blockTags = inputFormatValue
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
} else {
blockTags = []
}
} else {
blockTags = [normalizedBlockName]
}
} else {
if (blockConfig.category === 'triggers' || sourceBlock.type === 'starter') {
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
if (dynamicOutputs.length > 0) {
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
} else if (sourceBlock.type === 'starter') {
blockTags = [normalizedBlockName]
} else if (sourceBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK) {
blockTags = [normalizedBlockName]
} else {
blockTags = []
}
} else if (sourceBlock?.triggerMode && blockConfig.triggers?.enabled) {
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
if (dynamicOutputs.length > 0) {
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
} else {
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (sourceBlock.type === 'human_in_the_loop') {
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
const isSelfReference = activeSourceBlockId === blockId
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
} else {
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
}
} else {
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
if (toolOutputPaths.length > 0) {
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
} else {
const outputPaths = getBlockOutputPaths(
sourceBlock.type,
mergedSubBlocks,
sourceBlock.triggerMode
)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
}
}
@@ -1306,10 +1432,45 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const normalizedBlockName = normalizeName(blockName)
const mergedSubBlocks = getMergedSubBlocks(accessibleBlockId)
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
const responseFormat = parseResponseFormatSafely(responseFormatValue, accessibleBlockId)
let blockTags: string[]
if (accessibleBlock.type === 'variables') {
if (blockConfig.category === 'triggers' || accessibleBlock.type === 'starter') {
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
if (dynamicOutputs.length > 0) {
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
} else if (accessibleBlock.type === 'starter') {
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
if (startWorkflowValue === 'chat') {
blockTags = [
`${normalizedBlockName}.input`,
`${normalizedBlockName}.conversationId`,
`${normalizedBlockName}.files`,
]
} else {
blockTags = [normalizedBlockName]
}
} else if (accessibleBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK) {
blockTags = [normalizedBlockName]
} else {
blockTags = []
}
} else if (accessibleBlock.type === 'evaluator') {
const metricsValue = getSubBlockValue(accessibleBlockId, 'metrics')
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
blockTags = validMetrics.map(
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
)
} else {
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (accessibleBlock.type === 'variables') {
const variablesValue = getSubBlockValue(accessibleBlockId, 'variables')
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
@@ -1323,26 +1484,57 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
} else {
blockTags = [normalizedBlockName]
}
} else {
const accessibleBlockConfig = getBlock(accessibleBlock.type)
const isTriggerCapable = accessibleBlockConfig
? hasTriggerCapability(accessibleBlockConfig)
: false
const effectiveTriggerMode = Boolean(accessibleBlock.triggerMode && isTriggerCapable)
const outputPaths = getEffectiveBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
if (accessibleBlock.type === 'human_in_the_loop' && accessibleBlockId === blockId) {
blockTags = allTags.filter(
(tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')
)
} else if (allTags.length === 0) {
blockTags = [normalizedBlockName]
} else if (responseFormat) {
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {
blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`)
} else {
blockTags = allTags
const outputPaths = getBlockOutputPaths(
accessibleBlock.type,
mergedSubBlocks,
accessibleBlock.triggerMode
)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) {
blockTags = [normalizedBlockName]
} else {
const blockState = blocks[accessibleBlockId]
if (blockState?.triggerMode && blockConfig.triggers?.enabled) {
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
if (dynamicOutputs.length > 0) {
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
} else {
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (accessibleBlock.type === 'human_in_the_loop') {
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
const isSelfReference = accessibleBlockId === blockId
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
} else {
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
}
} else {
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
if (toolOutputPaths.length > 0) {
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
} else {
const outputPaths = getBlockOutputPaths(
accessibleBlock.type,
mergedSubBlocks,
accessibleBlock.triggerMode
)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
}
}

View File

@@ -452,7 +452,10 @@ export function VariablesInput({
ref={(el) => {
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
className={cn(
'absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm',
!isReadOnly && 'pointer-events-none'
)}
style={{ scrollbarWidth: 'none' }}
>
<div className='w-full whitespace-pre-wrap break-words'>
@@ -514,7 +517,10 @@ export function VariablesInput({
ref={(el) => {
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm',
!isReadOnly && 'pointer-events-none'
)}
style={{ scrollbarWidth: 'none' }}
>
<div

View File

@@ -1,8 +1,10 @@
import { useShallow } from 'zustand/react/shallow'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -18,7 +20,18 @@ export interface ConnectedBlock {
type: string
outputType: string | string[]
name: string
responseFormat?: {
// Support both formats
fields?: Field[]
name?: string
schema?: {
type: string
properties: Record<string, any>
required?: string[]
}
}
outputs?: Record<string, any>
operation?: string
}
export function useBlockConnections(blockId: string) {
@@ -89,32 +102,47 @@ export function useBlockConnections(blockId: string) {
// Get merged subblocks for this source block
const mergedSubBlocks = getMergedSubBlocks(sourceId)
const blockConfig = getBlock(sourceBlock.type)
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const effectiveTriggerMode = Boolean(sourceBlock.triggerMode && isTriggerCapable)
const blockOutputs = getEffectiveBlockOutputs(sourceBlock.type, mergedSubBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
// Get the response format from the subblock store
const responseFormatValue = useSubBlockStore.getState().getValue(sourceId, 'responseFormat')
const outputFields: Field[] = Object.entries(blockOutputs).map(
([key, value]: [string, any]) => ({
// Safely parse response format with proper error handling
const responseFormat = parseResponseFormatSafely(responseFormatValue, sourceId)
// Get operation value for tool-based blocks
const operationValue = useSubBlockStore.getState().getValue(sourceId, 'operation')
// Use getBlockOutputs to properly handle dynamic outputs from inputFormat
const blockOutputs = getBlockOutputs(
sourceBlock.type,
mergedSubBlocks,
sourceBlock.triggerMode
)
// Extract fields from the response format if available, otherwise use block outputs
let outputFields: Field[]
if (responseFormat) {
outputFields = extractFieldsFromSchema(responseFormat)
} else {
// Convert block outputs to field format
outputFields = Object.entries(blockOutputs).map(([key, value]: [string, any]) => ({
name: key,
type: value && typeof value === 'object' && 'type' in value ? value.type : 'string',
description:
value && typeof value === 'object' && 'description' in value
? value.description
: undefined,
})
)
}))
}
return {
id: sourceBlock.id,
type: sourceBlock.type,
outputType: outputFields.map((field: Field) => field.name),
name: sourceBlock.name,
responseFormat,
outputs: blockOutputs,
operation: operationValue,
distance: nodeDistances.get(sourceId) || Number.POSITIVE_INFINITY,
}
})

View File

@@ -1,8 +1,13 @@
'use client'
import { useMemo } from 'react'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { extractFieldsFromSchema } from '@/lib/core/utils/response-format'
import {
getBlockOutputPaths,
getBlockOutputs,
getToolOutputs,
} from '@/lib/workflows/blocks/block-outputs'
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
import type { SchemaField } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item'
import { getBlock } from '@/blocks'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -71,7 +76,11 @@ const extractNestedFields = (properties: Record<string, any>): SchemaField[] =>
/**
* Creates a schema field from an output definition
*/
const createFieldFromOutput = (name: string, output: any): SchemaField => {
const createFieldFromOutput = (
name: string,
output: any,
responseFormatFields?: SchemaField[]
): SchemaField => {
const hasExplicitType = isObject(output) && typeof output.type === 'string'
const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string'
@@ -81,7 +90,11 @@ const createFieldFromOutput = (name: string, output: any): SchemaField => {
description: isObject(output) && 'description' in output ? output.description : undefined,
}
field.children = extractChildFields(output)
if (name === 'data' && responseFormatFields && responseFormatFields.length > 0) {
field.children = responseFormatFields
} else {
field.children = extractChildFields(output)
}
return field
}
@@ -90,6 +103,8 @@ interface UseBlockOutputFieldsParams {
blockId: string
blockType: string
mergedSubBlocks?: Record<string, any>
responseFormat?: any
operation?: string
triggerMode?: boolean
}
@@ -101,6 +116,8 @@ export function useBlockOutputFields({
blockId,
blockType,
mergedSubBlocks,
responseFormat,
operation,
triggerMode,
}: UseBlockOutputFieldsParams): SchemaField[] {
return useMemo(() => {
@@ -121,6 +138,21 @@ export function useBlockOutputFields({
return []
}
// Handle evaluator blocks - use metrics if available
if (blockType === 'evaluator') {
const metricsValue = mergedSubBlocks?.metrics?.value ?? getSubBlockValue(blockId, 'metrics')
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
return validMetrics.map((metric: { name: string }) => ({
name: metric.name.toLowerCase(),
type: 'number',
description: `Metric: ${metric.name}`,
}))
}
// Fall through to use blockConfig.outputs
}
// Handle variables blocks - use variable assignments if available
if (blockType === 'variables') {
const variablesValue =
@@ -140,16 +172,123 @@ export function useBlockOutputFields({
return []
}
const isTriggerCapable = hasTriggerCapability(blockConfig)
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
const baseOutputs = getEffectiveBlockOutputs(blockType, mergedSubBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
}) as Record<string, any>
// Get base outputs using getBlockOutputs (handles triggers, starter, approval, etc.)
let baseOutputs: Record<string, any> = {}
if (blockConfig.category === 'triggers' || blockType === 'starter') {
// Use getBlockOutputPaths to get dynamic outputs, then reconstruct the structure
const outputPaths = getBlockOutputPaths(blockType, mergedSubBlocks, triggerMode)
if (outputPaths.length > 0) {
// Reconstruct outputs structure from paths
// This is a simplified approach - we'll use the paths to build the structure
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode)
} else if (blockType === 'starter') {
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
if (startWorkflowValue === 'chat') {
baseOutputs = {
input: { type: 'string', description: 'User message' },
conversationId: { type: 'string', description: 'Conversation ID' },
files: { type: 'file[]', description: 'Uploaded files' },
}
} else {
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
baseOutputs = {}
inputFormatValue.forEach((field: { name?: string; type?: string }) => {
if (field.name && field.name.trim() !== '') {
baseOutputs[field.name] = {
type: field.type || 'string',
description: `Field from input format`,
}
}
})
}
}
} else if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK) {
// Generic webhook returns the whole payload
baseOutputs = {}
} else {
baseOutputs = {}
}
} else if (triggerMode && blockConfig.triggers?.enabled) {
// Trigger mode enabled
const dynamicOutputs = getBlockOutputPaths(blockType, mergedSubBlocks, true)
if (dynamicOutputs.length > 0) {
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, true)
} else {
baseOutputs = blockConfig.outputs || {}
}
} else if (blockType === 'approval') {
// Approval block uses dynamic outputs from inputFormat
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks)
} else {
// For tool-based blocks, try to get tool outputs first
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, mergedSubBlocks) : {}
if (Object.keys(toolOutputs).length > 0) {
baseOutputs = toolOutputs
} else {
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode)
}
}
// Handle responseFormat
const responseFormatFields = responseFormat ? extractFieldsFromSchema(responseFormat) : []
// If responseFormat exists and has fields, merge with base outputs
if (responseFormatFields.length > 0) {
// If base outputs is empty, use responseFormat fields directly
if (Object.keys(baseOutputs).length === 0) {
return responseFormatFields.map((field) => ({
name: field.name,
type: field.type,
description: field.description,
children: undefined, // ResponseFormat fields are flat
}))
}
// Otherwise, merge: responseFormat takes precedence for 'data' field
const fields: SchemaField[] = []
const responseFormatFieldNames = new Set(responseFormatFields.map((f) => f.name))
// Add base outputs, replacing 'data' with responseFormat fields if present
for (const [name, output] of Object.entries(baseOutputs)) {
if (name === 'data' && responseFormatFields.length > 0) {
fields.push(
createFieldFromOutput(
name,
output,
responseFormatFields.map((f) => ({
name: f.name,
type: f.type,
description: f.description,
}))
)
)
} else if (!responseFormatFieldNames.has(name)) {
fields.push(createFieldFromOutput(name, output))
}
}
// Add responseFormat fields that aren't in base outputs
for (const field of responseFormatFields) {
if (!baseOutputs[field.name]) {
fields.push({
name: field.name,
type: field.type,
description: field.description,
})
}
}
return fields
}
// No responseFormat, just use base outputs
if (Object.keys(baseOutputs).length === 0) {
return []
}
return Object.entries(baseOutputs).map(([name, output]) => createFieldFromOutput(name, output))
}, [blockId, blockType, mergedSubBlocks, triggerMode])
}, [blockId, blockType, mergedSubBlocks, responseFormat, operation, triggerMode])
}

View File

@@ -43,7 +43,7 @@ import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/p
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { getBlock } from '@/blocks'
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
import type { BlockConfig, BlockIcon, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { navigatePath } from '@/executor/variables/resolvers/reference'
import { useWorkflowState } from '@/hooks/queries/workflows'
@@ -1140,9 +1140,17 @@ function PreviewEditorContent({
(block.advancedMode ?? false) ||
hasAdvancedValues(blockConfig.subBlocks, rawValues, canonicalIndex)
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
const effectiveTrigger = block.triggerMode === true
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (subBlock.type === ('trigger-config' as SubBlockType)) {
return effectiveTrigger || isPureTriggerBlock
}
if (subBlock.mode === 'trigger' && !effectiveTrigger) return false
if (effectiveTrigger && subBlock.mode !== 'trigger') return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (
!isSubBlockVisibleForMode(

View File

@@ -1,6 +1,8 @@
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks/registry'
import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { OutputSchema } from '@/executor/utils/block-reference'
@@ -10,6 +12,8 @@ import {
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
export interface BlockDataCollection {
blockData: Record<string, unknown>
@@ -17,44 +21,118 @@ export interface BlockDataCollection {
blockOutputSchemas: Record<string, OutputSchema>
}
interface SubBlockWithValue {
value?: unknown
}
/**
* Block types where inputFormat fields should be merged into outputs schema.
* These are blocks where users define custom fields via inputFormat that become
* valid output paths (e.g., <start.myField>, <webhook1.customField>, <hitl1.resumeField>).
*
* Note: This includes non-trigger blocks like 'starter' and 'human_in_the_loop' which
* have category 'blocks' but still need their inputFormat exposed as outputs.
*/
const BLOCKS_WITH_INPUT_FORMAT_OUTPUTS = [
'start_trigger',
'starter',
'api_trigger',
'input_trigger',
'generic_webhook',
'human_in_the_loop',
] as const
function paramsToSubBlocks(
params: Record<string, unknown> | undefined
): Record<string, SubBlockWithValue> {
if (!params) return {}
const subBlocks: Record<string, SubBlockWithValue> = {}
for (const [key, value] of Object.entries(params)) {
subBlocks[key] = { value }
function getInputFormatFields(block: SerializedBlock): OutputSchema {
const inputFormat = normalizeInputFormatValue(block.config?.params?.inputFormat)
if (inputFormat.length === 0) {
return {}
}
return subBlocks
const schema: OutputSchema = {}
for (const field of inputFormat) {
if (!field.name) continue
schema[field.name] = { type: field.type || 'any' }
}
return schema
}
function getRegistrySchema(block: SerializedBlock): OutputSchema | undefined {
function getEvaluatorMetricsSchema(block: SerializedBlock): OutputSchema | undefined {
if (block.metadata?.id !== 'evaluator') return undefined
const metrics = block.config?.params?.metrics
if (!Array.isArray(metrics) || metrics.length === 0) return undefined
const validMetrics = metrics.filter(
(m: { name?: string }) => m?.name && typeof m.name === 'string'
)
if (validMetrics.length === 0) return undefined
const schema: OutputSchema = { ...(block.outputs as OutputSchema) }
for (const metric of validMetrics) {
schema[metric.name.toLowerCase()] = { type: 'number' }
}
return schema
}
function getResponseFormatSchema(block: SerializedBlock): OutputSchema | undefined {
const responseFormatValue = block.config?.params?.responseFormat
if (!responseFormatValue) return undefined
const parsed = parseResponseFormatSafely(responseFormatValue, block.id)
if (!parsed) return undefined
const fields = extractFieldsFromSchema(parsed)
if (fields.length === 0) return undefined
const schema: OutputSchema = {}
for (const field of fields) {
schema[field.name] = { type: field.type || 'any' }
}
return schema
}
export function getBlockSchema(
block: SerializedBlock,
toolConfig?: ToolConfig
): OutputSchema | undefined {
const blockType = block.metadata?.id
if (!blockType) return undefined
const subBlocks = paramsToSubBlocks(block.config?.params)
const blockConfig = getBlock(blockType)
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const triggerMode = Boolean(isTriggerBehavior(block) && isTriggerCapable)
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, {
triggerMode,
preferToolOutputs: !triggerMode,
includeHidden: true,
}) as OutputSchema
if (!outputs || Object.keys(outputs).length === 0) {
return undefined
if (
blockType &&
BLOCKS_WITH_INPUT_FORMAT_OUTPUTS.includes(
blockType as (typeof BLOCKS_WITH_INPUT_FORMAT_OUTPUTS)[number]
)
) {
const baseOutputs = (block.outputs as OutputSchema) || {}
const inputFormatFields = getInputFormatFields(block)
const merged = { ...baseOutputs, ...inputFormatFields }
if (Object.keys(merged).length > 0) {
return merged
}
}
return outputs
}
export function getBlockSchema(block: SerializedBlock): OutputSchema | undefined {
return getRegistrySchema(block)
const evaluatorSchema = getEvaluatorMetricsSchema(block)
if (evaluatorSchema) {
return evaluatorSchema
}
const responseFormatSchema = getResponseFormatSchema(block)
if (responseFormatSchema) {
return responseFormatSchema
}
const isTrigger = isTriggerBehavior(block)
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
return toolConfig.outputs as OutputSchema
}
if (block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}
return undefined
}
export function collectBlockData(
@@ -92,7 +170,9 @@ export function collectBlockData(
blockNameMapping[normalizeName(block.metadata.name)] = id
}
const schema = getBlockSchema(block)
const toolId = block.config?.tool
const toolConfig = toolId ? getTool(toolId) : undefined
const schema = getBlockSchema(block, toolConfig)
if (schema && Object.keys(schema).length > 0) {
blockOutputSchemas[id] = schema
}

View File

@@ -5,10 +5,10 @@ import { BlockResolver } from './block'
import type { ResolutionContext } from './reference'
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/blocks/registry', async () => {
const actual = await vi.importActual<typeof import('@/blocks/registry')>('@/blocks/registry')
return actual
})
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
getBlockOutputs: vi.fn(() => ({})),
}))
function createTestWorkflow(
blocks: Array<{
@@ -135,7 +135,7 @@ describe('BlockResolver', () => {
})
it.concurrent('should return undefined for non-existent path when no schema defined', () => {
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
const workflow = createTestWorkflow([{ id: 'source' }])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
source: { existing: 'value' },
@@ -144,93 +144,55 @@ describe('BlockResolver', () => {
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
})
it.concurrent('should throw error for path not in output schema', () => {
it.concurrent('should throw error for path not in output schema', async () => {
const { getBlockOutputs } = await import('@/lib/workflows/blocks/block-outputs')
const mockGetBlockOutputs = vi.mocked(getBlockOutputs)
const customOutputs = {
validField: { type: 'string', description: 'A valid field' },
nested: {
child: { type: 'number', description: 'Nested child' },
},
}
mockGetBlockOutputs.mockReturnValue(customOutputs as any)
const workflow = createTestWorkflow([
{
id: 'source',
type: 'start_trigger',
outputs: customOutputs,
},
])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
source: { input: 'value' },
source: { validField: 'value', nested: { child: 42 } },
})
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(
/"invalidField" doesn't exist on block "source"/
)
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
mockGetBlockOutputs.mockReturnValue({})
})
it.concurrent('should return undefined for path in schema but missing in data', () => {
const workflow = createTestWorkflow([
{
id: 'source',
type: 'function',
outputs: {
requiredField: { type: 'string', description: 'Always present' },
optionalField: { type: 'string', description: 'Sometimes missing' },
},
},
])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
source: { stdout: 'log output' },
source: { requiredField: 'value' },
})
expect(resolver.resolve('<source.stdout>', ctx)).toBe('log output')
expect(resolver.resolve('<source.result>', ctx)).toBeUndefined()
expect(resolver.resolve('<source.requiredField>', ctx)).toBe('value')
expect(resolver.resolve('<source.optionalField>', ctx)).toBeUndefined()
})
it.concurrent(
'should allow hiddenFromDisplay fields for pre-execution schema validation',
() => {
const workflow = createTestWorkflow([
{
id: 'workflow-block',
name: 'Workflow',
type: 'workflow',
},
])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {})
expect(resolver.resolve('<workflow.childTraceSpans>', ctx)).toBeUndefined()
}
)
it.concurrent(
'should allow hiddenFromDisplay fields for workflow_input pre-execution schema validation',
() => {
const workflow = createTestWorkflow([
{
id: 'workflow-input-block',
name: 'Workflow Input',
type: 'workflow_input',
},
])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {})
expect(resolver.resolve('<workflowinput.childTraceSpans>', ctx)).toBeUndefined()
}
)
it.concurrent(
'should allow hiddenFromDisplay fields for HITL pre-execution schema validation',
() => {
const workflow = createTestWorkflow([
{
id: 'hitl-block',
name: 'HITL',
type: 'human_in_the_loop',
},
])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {})
expect(resolver.resolve('<hitl.response>', ctx)).toBeUndefined()
expect(resolver.resolve('<hitl.submission>', ctx)).toBeUndefined()
expect(resolver.resolve('<hitl.resumeInput>', ctx)).toBeUndefined()
}
)
it.concurrent('should return undefined for non-existent block', () => {
const workflow = createTestWorkflow([{ id: 'existing' }])
const resolver = new BlockResolver(workflow)
@@ -1013,7 +975,7 @@ describe('BlockResolver', () => {
})
it.concurrent('should handle output with undefined values', () => {
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
const workflow = createTestWorkflow([{ id: 'source' }])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
source: { value: undefined, other: 'exists' },
@@ -1023,7 +985,7 @@ describe('BlockResolver', () => {
})
it.concurrent('should return undefined for deeply nested non-existent path', () => {
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
const workflow = createTestWorkflow([{ id: 'source' }])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
source: { level1: { level2: {} } },

View File

@@ -17,6 +17,7 @@ import {
type Resolver,
} from '@/executor/variables/resolvers/reference'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { getTool } from '@/tools/utils'
export class BlockResolver implements Resolver {
private nameToBlockId: Map<string, string>
@@ -67,7 +68,9 @@ export class BlockResolver implements Resolver {
blockData[blockId] = output
}
const outputSchema = getBlockSchema(block)
const toolId = block.config?.tool
const toolConfig = toolId ? getTool(toolId) : undefined
const outputSchema = getBlockSchema(block, toolConfig)
if (outputSchema && Object.keys(outputSchema).length > 0) {
blockOutputSchemas[blockId] = outputSchema

View File

@@ -1,10 +1,7 @@
import { createLogger } from '@sim/logger'
import { processFileAttachments } from '@/lib/copilot/chat-context'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import type { CopilotProviderConfig } from '@/lib/copilot/types'
import { env } from '@/lib/core/config/env'
import { tools } from '@/tools/registry'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
@@ -46,57 +43,12 @@ interface CredentialsPayload {
}
}
function buildProviderConfig(selectedModel: string): CopilotProviderConfig | undefined {
const defaults = getCopilotModel('chat')
const envModel = env.COPILOT_MODEL || defaults.model
const providerEnv = env.COPILOT_PROVIDER
if (!providerEnv) return undefined
if (providerEnv === 'azure-openai') {
return {
provider: 'azure-openai',
model: envModel,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: 'preview',
endpoint: env.AZURE_OPENAI_ENDPOINT,
}
}
if (providerEnv === 'azure-anthropic') {
return {
provider: 'azure-anthropic',
model: envModel,
apiKey: env.AZURE_ANTHROPIC_API_KEY,
apiVersion: env.AZURE_ANTHROPIC_API_VERSION,
endpoint: env.AZURE_ANTHROPIC_ENDPOINT,
}
}
if (providerEnv === 'vertex') {
return {
provider: 'vertex',
model: envModel,
apiKey: env.COPILOT_API_KEY,
vertexProject: env.VERTEX_PROJECT,
vertexLocation: env.VERTEX_LOCATION,
}
}
return {
provider: providerEnv as Exclude<string, 'azure-openai' | 'vertex'>,
model: selectedModel,
apiKey: env.COPILOT_API_KEY,
} as CopilotProviderConfig
}
/**
* Build the request payload for the copilot backend.
*/
export async function buildCopilotRequestPayload(
params: BuildPayloadParams,
options: {
providerConfig?: CopilotProviderConfig
selectedModel: string
}
): Promise<Record<string, unknown>> {
@@ -113,7 +65,6 @@ export async function buildCopilotRequestPayload(
} = params
const selectedModel = options.selectedModel
const providerConfig = options.providerConfig ?? buildProviderConfig(selectedModel)
const effectiveMode = mode === 'agent' ? 'build' : mode
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
@@ -198,7 +149,6 @@ export async function buildCopilotRequestPayload(
mode: transportMode,
messageId: userMessageId,
version: SIM_AGENT_VERSION,
...(providerConfig ? { provider: providerConfig } : {}),
...(contexts && contexts.length > 0 ? { context: contexts } : {}),
...(chatId ? { chatId } : {}),
...(processedFileContents.length > 0 ? { fileAttachments: processedFileContents } : {}),

View File

@@ -104,6 +104,9 @@ export const COPILOT_CHECKPOINTS_REVERT_API_PATH = '/api/copilot/checkpoints/rev
/** GET/POST/DELETE — manage auto-allowed tools. */
export const COPILOT_AUTO_ALLOWED_TOOLS_API_PATH = '/api/copilot/auto-allowed-tools'
/** GET — fetch dynamically available copilot models. */
export const COPILOT_MODELS_API_PATH = '/api/copilot/models'
/** GET — fetch user credentials for masking. */
export const COPILOT_CREDENTIALS_API_PATH = '/api/copilot/credentials'

View File

@@ -24,7 +24,7 @@ export const COPILOT_MODEL_IDS = [
'gemini-3-pro',
] as const
export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number]
export type CopilotModelId = string
export const COPILOT_MODES = ['ask', 'build', 'plan'] as const
export type CopilotMode = (typeof COPILOT_MODES)[number]

View File

@@ -8,15 +8,13 @@ import {
} from '@/lib/copilot/tools/shared/workflow-utils'
import { mcpService } from '@/lib/mcp/service'
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks/registry'
import { normalizeName } from '@/executor/constants'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
import {
@@ -345,13 +343,7 @@ export async function executeGetBlockOutputs(
continue
}
const blockConfig = getBlock(block.type)
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const triggerMode = Boolean(block.triggerMode && isTriggerCapable)
const outputs = getEffectiveBlockOutputPaths(block.type, block.subBlocks, {
triggerMode,
preferToolOutputs: !triggerMode,
})
const outputs = getBlockOutputPaths(block.type, block.subBlocks, block.triggerMode)
results.push({
blockId,
blockName,
@@ -493,13 +485,7 @@ export async function executeGetBlockUpstreamReferences(
? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels)
: ['results']
} else {
const blockConfig = getBlock(block.type)
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const triggerMode = Boolean(block.triggerMode && isTriggerCapable)
outputPaths = getEffectiveBlockOutputPaths(block.type, block.subBlocks, {
triggerMode,
preferToolOutputs: !triggerMode,
})
outputPaths = getBlockOutputPaths(block.type, block.subBlocks, block.triggerMode)
}
const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName)

View File

@@ -232,11 +232,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
const resolvedToolId = resolveToolIdForOperation(blockConfig, opId)
const toolCfg = resolvedToolId ? toolsRegistry[resolvedToolId] : undefined
const toolParams: Record<string, any> = toolCfg?.params || {}
const toolOutputs: Record<string, any> = toolCfg?.outputs
? Object.fromEntries(
Object.entries(toolCfg.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
)
: {}
const toolOutputs: Record<string, any> = toolCfg?.outputs || {}
const filteredToolParams: Record<string, any> = {}
for (const [k, v] of Object.entries(toolParams)) {
if (!(k in blockInputs)) filteredToolParams[k] = v

View File

@@ -1,44 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { createBlockFromParams } from './builders'
const agentBlockConfig = {
type: 'agent',
name: 'Agent',
outputs: {
content: { type: 'string', description: 'Default content output' },
},
subBlocks: [{ id: 'responseFormat', type: 'response-format' }],
}
vi.mock('@/blocks/registry', () => ({
getAllBlocks: () => [agentBlockConfig],
getBlock: (type: string) => (type === 'agent' ? agentBlockConfig : undefined),
}))
describe('createBlockFromParams', () => {
it('derives agent outputs from responseFormat when outputs are not provided', () => {
const block = createBlockFromParams('b-agent', {
type: 'agent',
name: 'Agent',
inputs: {
responseFormat: {
type: 'object',
properties: {
answer: {
type: 'string',
description: 'Structured answer text',
},
},
required: ['answer'],
},
},
triggerMode: false,
})
expect(block.outputs.answer).toBeDefined()
expect(block.outputs.answer.type).toBe('string')
})
})

View File

@@ -1,9 +1,8 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -40,8 +39,6 @@ export function createBlockFromParams(
// Determine outputs based on trigger mode
const triggerMode = params.triggerMode || false
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
let outputs: Record<string, any>
if (params.outputs) {
@@ -57,10 +54,7 @@ export function createBlockFromParams(
subBlocks[key] = { id: key, type: 'short-input', value: value }
})
}
outputs = getEffectiveBlockOutputs(params.type, subBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
outputs = getBlockOutputs(params.type, subBlocks, triggerMode)
} else {
outputs = {}
}

View File

@@ -11,6 +11,12 @@ export type NotificationStatus =
export type { CopilotToolCall, ToolState }
export interface AvailableModel {
id: string
friendlyName: string
provider: string
}
// Provider configuration for Sim Agent requests.
// This type is only for the `provider` field in requests sent to the Sim Agent.
export type CopilotProviderConfig =

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getEffectiveBlockOutputPaths,
getEffectiveBlockOutputs,
getEffectiveBlockOutputType,
} from '@/lib/workflows/blocks/block-outputs'
type SubBlocks = Record<string, { value: unknown }>
function rootPaths(paths: string[]): string[] {
return [...new Set(paths.map((path) => path.split('.')[0]).filter(Boolean))].sort()
}
describe('block outputs parity', () => {
it.concurrent('keeps evaluator tag paths and types aligned', () => {
const subBlocks: SubBlocks = {
metrics: {
value: [
{
name: 'Accuracy',
description: 'How accurate the answer is',
range: { min: 0, max: 1 },
},
{
name: 'Relevance',
description: 'How relevant the answer is',
range: { min: 0, max: 1 },
},
],
},
}
const options = { triggerMode: false, preferToolOutputs: true }
const outputs = getEffectiveBlockOutputs('evaluator', subBlocks, options)
const paths = getEffectiveBlockOutputPaths('evaluator', subBlocks, options)
expect(rootPaths(paths)).toEqual(Object.keys(outputs).sort())
expect(paths).toContain('accuracy')
expect(paths).toContain('relevance')
expect(getEffectiveBlockOutputType('evaluator', 'accuracy', subBlocks, options)).toBe('number')
expect(getEffectiveBlockOutputType('evaluator', 'relevance', subBlocks, options)).toBe('number')
})
it.concurrent('keeps agent responseFormat tag paths and types aligned', () => {
const subBlocks: SubBlocks = {
responseFormat: {
value: {
name: 'calculator_output',
schema: {
type: 'object',
properties: {
min: { type: 'number' },
max: { type: 'number' },
},
required: ['min', 'max'],
additionalProperties: false,
},
strict: true,
},
},
}
const options = { triggerMode: false, preferToolOutputs: true }
const outputs = getEffectiveBlockOutputs('agent', subBlocks, options)
const paths = getEffectiveBlockOutputPaths('agent', subBlocks, options)
expect(rootPaths(paths)).toEqual(Object.keys(outputs).sort())
expect(paths).toContain('min')
expect(paths).toContain('max')
expect(getEffectiveBlockOutputType('agent', 'min', subBlocks, options)).toBe('number')
expect(getEffectiveBlockOutputType('agent', 'max', subBlocks, options)).toBe('number')
})
})

View File

@@ -33,12 +33,6 @@ interface SubBlockWithValue {
value?: unknown
}
interface EffectiveOutputOptions {
triggerMode?: boolean
preferToolOutputs?: boolean
includeHidden?: boolean
}
type ConditionValue = string | number | boolean
/**
@@ -102,13 +96,12 @@ function evaluateOutputCondition(
*/
function filterOutputsByCondition(
outputs: OutputDefinition,
subBlocks: Record<string, SubBlockWithValue> | undefined,
includeHidden = false
subBlocks: Record<string, SubBlockWithValue> | undefined
): OutputDefinition {
const filtered: OutputDefinition = {}
for (const [key, value] of Object.entries(outputs)) {
if (!includeHidden && isHiddenFromDisplay(value)) continue
if (isHiddenFromDisplay(value)) continue
if (!value || typeof value !== 'object' || !('condition' in value)) {
filtered[key] = value
@@ -119,13 +112,8 @@ function filterOutputsByCondition(
const passes = !condition || evaluateOutputCondition(condition, subBlocks)
if (passes) {
if (includeHidden) {
const { condition: _, ...rest } = value
filtered[key] = rest
} else {
const { condition: _, hiddenFromDisplay: __, ...rest } = value
filtered[key] = rest
}
const { condition: _, hiddenFromDisplay: __, ...rest } = value
filtered[key] = rest
}
}
@@ -255,10 +243,8 @@ function applyInputFormatToOutputs(
export function getBlockOutputs(
blockType: string,
subBlocks?: Record<string, SubBlockWithValue>,
triggerMode?: boolean,
options?: { includeHidden?: boolean }
triggerMode?: boolean
): OutputDefinition {
const includeHidden = options?.includeHidden ?? false
const blockConfig = getBlock(blockType)
if (!blockConfig) return {}
@@ -283,8 +269,7 @@ export function getBlockOutputs(
// Start with block config outputs (respects hiddenFromDisplay via filterOutputsByCondition)
const baseOutputs = filterOutputsByCondition(
{ ...(blockConfig.outputs || {}) } as OutputDefinition,
subBlocks,
includeHidden
subBlocks
)
// Add inputFormat fields (resume form fields)
@@ -307,111 +292,29 @@ export function getBlockOutputs(
return getLegacyStarterOutputs(subBlocks)
}
const baseOutputs = { ...(blockConfig.outputs || {}) }
const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks, includeHidden)
return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs)
}
export function getResponseFormatOutputs(
subBlocks?: Record<string, SubBlockWithValue>,
blockId = 'block'
): OutputDefinition | undefined {
const responseFormatValue = subBlocks?.responseFormat?.value
if (!responseFormatValue) return undefined
const parsed = parseResponseFormatSafely(responseFormatValue, blockId)
if (!parsed) return undefined
const fields = extractFieldsFromSchema(parsed)
if (fields.length === 0) return undefined
const outputs: OutputDefinition = {}
for (const field of fields) {
outputs[field.name] = {
type: (field.type || 'any') as any,
description: field.description || `Field from Agent: ${field.name}`,
}
}
return outputs
}
export function getEvaluatorMetricOutputs(
subBlocks?: Record<string, SubBlockWithValue>
): OutputDefinition | undefined {
const metricsValue = subBlocks?.metrics?.value
if (!metricsValue || !Array.isArray(metricsValue) || metricsValue.length === 0) return undefined
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
if (validMetrics.length === 0) return undefined
const outputs: OutputDefinition = {}
for (const metric of validMetrics as Array<{ name: string }>) {
outputs[metric.name.toLowerCase()] = {
type: 'number',
description: `Metric score: ${metric.name}`,
}
}
return outputs
}
export function getEffectiveBlockOutputs(
blockType: string,
subBlocks?: Record<string, SubBlockWithValue>,
options?: EffectiveOutputOptions
): OutputDefinition {
const triggerMode = options?.triggerMode ?? false
const preferToolOutputs = options?.preferToolOutputs ?? !triggerMode
const includeHidden = options?.includeHidden ?? false
if (blockType === 'agent') {
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
if (responseFormatOutputs) return responseFormatOutputs
}
let baseOutputs: OutputDefinition
if (triggerMode) {
baseOutputs = getBlockOutputs(blockType, subBlocks, true, { includeHidden })
} else if (preferToolOutputs) {
const blockConfig = getBlock(blockType)
const toolOutputs = blockConfig
? (getToolOutputs(blockConfig, subBlocks, { includeHidden }) as OutputDefinition)
: {}
baseOutputs =
toolOutputs && Object.keys(toolOutputs).length > 0
? toolOutputs
: getBlockOutputs(blockType, subBlocks, false, { includeHidden })
} else {
baseOutputs = getBlockOutputs(blockType, subBlocks, false, { includeHidden })
}
if (blockType === 'evaluator') {
const metricOutputs = getEvaluatorMetricOutputs(subBlocks)
if (metricOutputs) {
return { ...baseOutputs, ...metricOutputs }
const responseFormatValue = subBlocks?.responseFormat?.value
if (responseFormatValue) {
const parsed = parseResponseFormatSafely(responseFormatValue, 'agent')
if (parsed) {
const fields = extractFieldsFromSchema(parsed)
if (fields.length > 0) {
const outputs: OutputDefinition = {}
for (const field of fields) {
outputs[field.name] = {
type: (field.type || 'any') as any,
description: field.description || `Field from Agent: ${field.name}`,
}
}
return outputs
}
}
}
}
return baseOutputs
}
export function getEffectiveBlockOutputPaths(
blockType: string,
subBlocks?: Record<string, SubBlockWithValue>,
options?: EffectiveOutputOptions
): string[] {
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, options)
const paths = generateOutputPaths(outputs)
if (blockType === TRIGGER_TYPES.START) {
return paths.filter((path) => {
const key = path.split('.')[0]
return !shouldFilterReservedField(blockType, key, '', subBlocks)
})
}
return paths
const baseOutputs = { ...(blockConfig.outputs || {}) }
const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks)
return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs)
}
function shouldFilterReservedField(
@@ -449,6 +352,24 @@ function isFileOutputDefinition(value: unknown): value is { type: FileOutputType
return type === 'file' || type === 'file[]'
}
export function getBlockOutputPaths(
blockType: string,
subBlocks?: Record<string, SubBlockWithValue>,
triggerMode?: boolean
): string[] {
const outputs = getBlockOutputs(blockType, subBlocks, triggerMode)
const paths = generateOutputPaths(outputs)
if (blockType === TRIGGER_TYPES.START) {
return paths.filter((path) => {
const key = path.split('.')[0]
return !shouldFilterReservedField(blockType, key, '', subBlocks)
})
}
return paths
}
function getFilePropertyType(outputs: OutputDefinition, pathParts: string[]): string | null {
const lastPart = pathParts[pathParts.length - 1]
if (!lastPart || !USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES]) {
@@ -532,13 +453,13 @@ function extractType(value: unknown): string {
return typeof value === 'string' ? value : 'any'
}
export function getEffectiveBlockOutputType(
export function getBlockOutputType(
blockType: string,
outputPath: string,
subBlocks?: Record<string, SubBlockWithValue>,
options?: EffectiveOutputOptions
triggerMode?: boolean
): string {
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, options)
const outputs = getBlockOutputs(blockType, subBlocks, triggerMode)
const cleanPath = outputPath.replace(/\[(\d+)\]/g, '')
const pathParts = cleanPath.split('.').filter(Boolean)
@@ -610,6 +531,60 @@ function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[
return paths
}
/**
* Recursively generates all output paths with their types from an outputs schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of objects containing path and type for each output field
*/
function generateOutputPathsWithTypes(
outputs: Record<string, any>,
prefix = ''
): Array<{ path: string; type: string }> {
const paths: Array<{ path: string; type: string }> = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (isFileOutputDefinition(value)) {
paths.push({ path: currentPath, type: value.type })
for (const prop of USER_FILE_ACCESSIBLE_PROPERTIES) {
paths.push({
path: `${currentPath}.${prop}`,
type: USER_FILE_PROPERTY_TYPES[prop as keyof typeof USER_FILE_PROPERTY_TYPES],
})
}
continue
}
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
paths.push(...subPaths)
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push({ path: currentPath, type: value.type })
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
paths.push(...subPaths)
} else {
paths.push({ path: currentPath, type: value.type })
}
} else {
const subPaths = generateOutputPathsWithTypes(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push({ path: currentPath, type: 'any' })
}
}
return paths
}
/**
* Gets the tool outputs for a block operation.
*
@@ -619,10 +594,8 @@ function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[
*/
export function getToolOutputs(
blockConfig: BlockConfig,
subBlocks?: Record<string, SubBlockWithValue>,
options?: { includeHidden?: boolean }
subBlocks?: Record<string, SubBlockWithValue>
): Record<string, any> {
const includeHidden = options?.includeHidden ?? false
if (!blockConfig?.tools?.config?.tool) return {}
try {
@@ -640,18 +613,49 @@ export function getToolOutputs(
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return {}
if (includeHidden) {
return toolConfig.outputs
}
return Object.fromEntries(
Object.entries(toolConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
)
return toolConfig.outputs
} catch (error) {
logger.warn('Failed to get tool outputs', { error })
return {}
}
}
export function getToolOutputPaths(
blockConfig: BlockConfig,
subBlocks?: Record<string, SubBlockWithValue>
): string[] {
const outputs = getToolOutputs(blockConfig, subBlocks)
if (!outputs || Object.keys(outputs).length === 0) return []
if (subBlocks && blockConfig.outputs) {
const filteredOutputs: Record<string, any> = {}
for (const [key, value] of Object.entries(outputs)) {
const blockOutput = blockConfig.outputs[key]
if (!blockOutput || typeof blockOutput !== 'object') {
filteredOutputs[key] = value
continue
}
const condition = 'condition' in blockOutput ? blockOutput.condition : undefined
if (condition) {
if (evaluateOutputCondition(condition, subBlocks)) {
filteredOutputs[key] = value
}
} else {
filteredOutputs[key] = value
}
}
return generateOutputPaths(filteredOutputs)
}
return generateOutputPaths(outputs)
}
/**
* Generates output paths from a schema definition.
*
@@ -661,3 +665,24 @@ export function getToolOutputs(
export function getOutputPathsFromSchema(outputs: Record<string, any>): string[] {
return generateOutputPaths(outputs)
}
/**
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param subBlocks - SubBlock values for tool selection
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
export function getToolOutputType(
blockConfig: BlockConfig,
subBlocks: Record<string, SubBlockWithValue> | undefined,
path: string
): string {
const outputs = getToolOutputs(blockConfig, subBlocks)
if (!outputs || Object.keys(outputs).length === 0) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(outputs)
const matchingPath = pathsWithTypes.find((p) => p.path === path)
return matchingPath?.type || 'any'
}

View File

@@ -1,4 +1,4 @@
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -85,10 +85,7 @@ function buildStartBlockState(
subBlockValues[config.id] = initialValue ?? null
})
const outputs = getEffectiveBlockOutputs(blockConfig.type, subBlocks, {
triggerMode: false,
preferToolOutputs: true,
})
const outputs = getBlockOutputs(blockConfig.type, subBlocks)
const blockState: BlockState = {
id: blockId,

View File

@@ -26,6 +26,7 @@ import {
COPILOT_CONFIRM_API_PATH,
COPILOT_CREDENTIALS_API_PATH,
COPILOT_DELETE_CHAT_API_PATH,
COPILOT_MODELS_API_PATH,
MAX_RESUME_ATTEMPTS,
OPTIMISTIC_TITLE_MAX_LENGTH,
QUEUE_PROCESS_DELAY_MS,
@@ -41,6 +42,7 @@ import {
saveMessageCheckpoint,
} from '@/lib/copilot/messages'
import type { CopilotTransportMode } from '@/lib/copilot/models'
import type { AvailableModel } from '@/lib/copilot/types'
import { parseSSEStream } from '@/lib/copilot/orchestrator/sse-parser'
import {
abortAllInProgressTools,
@@ -913,6 +915,8 @@ const initialState = {
selectedModel: 'claude-4.6-opus' as CopilotStore['selectedModel'],
agentPrefetch: false,
enabledModels: null as string[] | null, // Null means not loaded yet, empty array means all disabled
availableModels: [] as AvailableModel[],
isLoadingModels: false,
isCollapsed: false,
currentChat: null as CopilotChat | null,
chats: [] as CopilotChat[],
@@ -979,6 +983,8 @@ export const useCopilotStore = create<CopilotStore>()(
selectedModel: get().selectedModel,
agentPrefetch: get().agentPrefetch,
enabledModels: get().enabledModels,
availableModels: get().availableModels,
isLoadingModels: get().isLoadingModels,
autoAllowedTools: get().autoAllowedTools,
autoAllowedToolsLoaded: get().autoAllowedToolsLoaded,
})
@@ -2191,6 +2197,49 @@ export const useCopilotStore = create<CopilotStore>()(
},
setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }),
setEnabledModels: (models) => set({ enabledModels: models }),
loadAvailableModels: async () => {
set({ isLoadingModels: true })
try {
const response = await fetch(COPILOT_MODELS_API_PATH, { method: 'GET' })
if (!response.ok) {
throw new Error(`Failed to fetch available models: ${response.status}`)
}
const data = await response.json()
const models: unknown[] = Array.isArray(data?.models) ? data.models : []
const normalizedModels: AvailableModel[] = models
.filter((model: unknown): model is AvailableModel => {
return (
typeof model === 'object' &&
model !== null &&
'id' in model &&
typeof (model as { id: unknown }).id === 'string'
)
})
.map((model: AvailableModel) => ({
id: model.id,
friendlyName: model.friendlyName || model.id,
provider: model.provider || 'unknown',
}))
const { selectedModel } = get()
const selectedModelExists = normalizedModels.some((model) => model.id === selectedModel)
const nextSelectedModel =
selectedModelExists || normalizedModels.length === 0 ? selectedModel : normalizedModels[0].id
set({
availableModels: normalizedModels,
selectedModel: nextSelectedModel as CopilotStore['selectedModel'],
isLoadingModels: false,
})
} catch (error) {
logger.warn('[Copilot] Failed to load available models', {
error: error instanceof Error ? error.message : String(error),
})
set({ isLoadingModels: false })
}
},
loadAutoAllowedTools: async () => {
try {

View File

@@ -1,4 +1,5 @@
import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
import type { AvailableModel } from '@/lib/copilot/types'
export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
@@ -116,6 +117,8 @@ export interface CopilotState {
selectedModel: CopilotModelId
agentPrefetch: boolean
enabledModels: string[] | null // Null means not loaded yet, array of model IDs when loaded
availableModels: AvailableModel[]
isLoadingModels: boolean
isCollapsed: boolean
currentChat: CopilotChat | null
@@ -184,6 +187,7 @@ export interface CopilotActions {
setSelectedModel: (model: CopilotStore['selectedModel']) => Promise<void>
setAgentPrefetch: (prefetch: boolean) => void
setEnabledModels: (models: string[] | null) => void
loadAvailableModels: () => Promise<void>
setWorkflowId: (workflowId: string | null) => Promise<void>
validateCurrentChat: () => boolean

View File

@@ -1,9 +1,8 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
@@ -189,12 +188,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
})
}
const isTriggerCapable = hasTriggerCapability(blockConfig)
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
const outputs = getEffectiveBlockOutputs(type, subBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
const outputs = getBlockOutputs(type, subBlocks, triggerMode)
return {
id,