feat(copilot): fix context / json parsing edge cases (#1542)

* Add get ops examples

* input format incorrectly created by copilot should not crash workflow

* fix tool edits triggering overall delta

* fix(db): add more options for SSL connection, add envvar for base64 db cert (#1533)

* fix trigger additions

* fix nested outputs for triggers

* add condition subblock sanitization

* fix custom tools json

* Model selector

* fix response format sanitization

* remove dead code

* fix export sanitization

* Update migration

* fix import race cond

* Copilot settings

* fix response format

* stop loops/parallels copilot generation from breaking diff view

* fix lint

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix tests

* fix lint

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Vikhyath Mondreti
2025-10-03 19:08:57 -07:00
committed by GitHub
parent 4da355d269
commit c42d2a32f3
23 changed files with 8301 additions and 451 deletions

View File

@@ -233,7 +233,7 @@ describe('Copilot Chat API Route', () => {
model: 'claude-4.5-sonnet',
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
version: '1.0.0',
version: '1.0.1',
chatId: 'chat-123',
}),
})
@@ -303,7 +303,7 @@ describe('Copilot Chat API Route', () => {
model: 'claude-4.5-sonnet',
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
version: '1.0.0',
version: '1.0.1',
chatId: 'chat-123',
}),
})
@@ -361,7 +361,7 @@ describe('Copilot Chat API Route', () => {
model: 'claude-4.5-sonnet',
mode: 'agent',
messageId: 'mock-uuid-1234-5678',
version: '1.0.0',
version: '1.0.1',
chatId: 'chat-123',
}),
})
@@ -453,7 +453,7 @@ describe('Copilot Chat API Route', () => {
model: 'claude-4.5-sonnet',
mode: 'ask',
messageId: 'mock-uuid-1234-5678',
version: '1.0.0',
version: '1.0.1',
chatId: 'chat-123',
}),
})

View File

@@ -0,0 +1,131 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/../../packages/db'
import { settings } from '@/../../packages/db/schema'
const logger = createLogger('CopilotUserModelsAPI')
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
'gpt-4o': false,
'gpt-4.1': false,
'gpt-5-fast': false,
'gpt-5': true,
'gpt-5-medium': true,
'gpt-5-high': false,
o3: true,
'claude-4-sonnet': true,
'claude-4.5-sonnet': true,
'claude-4.1-opus': true,
}
// GET - Fetch user's enabled models
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Try to fetch existing settings record
const [userSettings] = await db
.select()
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
if (userSettings) {
const userModelsMap = (userSettings.copilotEnabledModels as Record<string, boolean>) || {}
// Merge: start with defaults, then override with user's existing preferences
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
mergedModels[modelId] = enabled
}
// If we added any new models, update the database
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(
(key) => !(key in userModelsMap)
)
if (hasNewModels) {
await db
.update(settings)
.set({
copilotEnabledModels: mergedModels,
updatedAt: new Date(),
})
.where(eq(settings.userId, userId))
}
return NextResponse.json({
enabledModels: mergedModels,
})
}
// If no settings record exists, create one with empty object (client will use defaults)
const [created] = await db
.insert(settings)
.values({
id: userId,
userId,
copilotEnabledModels: {},
})
.returning()
return NextResponse.json({
enabledModels: DEFAULT_ENABLED_MODELS,
})
} catch (error) {
logger.error('Failed to fetch user models', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// PUT - Update user's enabled models
export async function PUT(request: NextRequest) {
try {
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const body = await request.json()
if (!body.enabledModels || typeof body.enabledModels !== 'object') {
return NextResponse.json({ error: 'enabledModels must be an object' }, { status: 400 })
}
// Check if settings record exists
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (existing) {
// Update existing record
await db
.update(settings)
.set({
copilotEnabledModels: body.enabledModels,
updatedAt: new Date(),
})
.where(eq(settings.userId, userId))
} else {
// Create new settings record
await db.insert(settings).values({
id: userId,
userId,
copilotEnabledModels: body.enabledModels,
})
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to update user models', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -3,6 +3,7 @@
import {
forwardRef,
type KeyboardEvent,
useCallback,
useEffect,
useImperativeHandle,
useRef,
@@ -41,7 +42,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Switch,
Textarea,
Tooltip,
TooltipContent,
@@ -49,6 +49,7 @@ import {
TooltipTrigger,
} from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { isHosted } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useCopilotStore } from '@/stores/copilot/store'
@@ -92,6 +93,7 @@ interface UserInputProps {
onModeChange?: (mode: 'ask' | 'agent') => void
value?: string // Controlled value from outside
onChange?: (value: string) => void // Callback when value changes
panelWidth?: number // Panel width to adjust truncation
}
interface UserInputRef {
@@ -112,6 +114,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onModeChange,
value: controlledValue,
onChange: onControlledChange,
panelWidth = 308,
},
ref
) => {
@@ -179,7 +182,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const { data: session } = useSession()
const { currentChat, workflowId } = useCopilotStore()
const { currentChat, workflowId, enabledModels, setEnabledModels } = useCopilotStore()
const params = useParams()
const workspaceId = params.workspaceId as string
// Track per-chat preference for auto-adding workflow context
@@ -224,6 +227,30 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [workflowId])
// Fetch enabled models when dropdown is opened for the first time
const fetchEnabledModelsOnce = useCallback(async () => {
if (!isHosted) return
if (enabledModels !== null) return // Already loaded
try {
const res = await fetch('/api/copilot/user-models')
if (!res.ok) {
logger.error('Failed to fetch enabled models')
return
}
const data = await res.json()
const modelsMap = data.enabledModels || {}
// Convert to array for store (API already merged with defaults)
const enabledArray = Object.entries(modelsMap)
.filter(([_, enabled]) => enabled)
.map(([modelId]) => modelId)
setEnabledModels(enabledArray)
} catch (error) {
logger.error('Error fetching enabled models', { error })
}
}, [enabledModels, setEnabledModels])
// Track the last chat ID we've seen to detect chat changes
const [lastChatId, setLastChatId] = useState<string | undefined>(undefined)
// Track if we just sent a message to avoid re-adding context after submit
@@ -1780,7 +1807,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const { selectedModel, agentPrefetch, setSelectedModel, setAgentPrefetch } = useCopilotStore()
// Model configurations with their display names
const modelOptions = [
const allModelOptions = [
{ value: 'gpt-5-fast', label: 'gpt-5-fast' },
{ value: 'gpt-5', label: 'gpt-5' },
{ value: 'gpt-5-medium', label: 'gpt-5-medium' },
@@ -1793,23 +1820,36 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
{ value: 'claude-4.1-opus', label: 'claude-4.1-opus' },
] as const
// Filter models based on user preferences (only for hosted)
const modelOptions =
isHosted && enabledModels !== null
? allModelOptions.filter((model) => enabledModels.includes(model.value))
: allModelOptions
const getCollapsedModeLabel = () => {
const model = modelOptions.find((m) => m.value === selectedModel)
return model ? model.label : 'Claude 4.5 Sonnet'
return model ? model.label : 'claude-4.5-sonnet'
}
const getModelIcon = () => {
const colorClass = !agentPrefetch
? 'text-[var(--brand-primary-hover-hex)]'
: 'text-muted-foreground'
// Only Brain and BrainCircuit models show purple when agentPrefetch is false
const isBrainModel = [
'gpt-5',
'gpt-5-medium',
'claude-4-sonnet',
'claude-4.5-sonnet',
].includes(selectedModel)
const isBrainCircuitModel = ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(selectedModel)
const colorClass =
(isBrainModel || isBrainCircuitModel) && !agentPrefetch
? 'text-[var(--brand-primary-hover-hex)]'
: 'text-muted-foreground'
// Match the dropdown icon logic exactly
if (['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(selectedModel)) {
if (isBrainCircuitModel) {
return <BrainCircuit className={`h-3 w-3 ${colorClass}`} />
}
if (
['gpt-5', 'gpt-5-medium', 'claude-4-sonnet', 'claude-4.5-sonnet'].includes(selectedModel)
) {
if (isBrainModel) {
return <Brain className={`h-3 w-3 ${colorClass}`} />
}
if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel)) {
@@ -3068,7 +3108,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
variant='ghost'
size='sm'
disabled={!onModeChange}
className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs'
className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs focus-visible:ring-0 focus-visible:ring-offset-0'
>
{getModeIcon()}
<span>{getModeText()}</span>
@@ -3134,191 +3174,183 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
</TooltipProvider>
</DropdownMenuContent>
</DropdownMenu>
{
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className={cn(
'flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs',
!agentPrefetch
? 'border-[var(--brand-primary-hover-hex)] text-[var(--brand-primary-hover-hex)] hover:bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_8%,transparent)] hover:text-[var(--brand-primary-hover-hex)]'
: 'border-border text-foreground'
)}
title='Choose mode'
>
{getModelIcon()}
<span>
{getCollapsedModeLabel()}
{!agentPrefetch &&
!['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel) && (
<span className='ml-1 font-semibold'>MAX</span>
)}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='top' className='max-h-[400px] p-0'>
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
<div className='w-[220px]'>
<div className='p-2 pb-0'>
<div className='mb-2 flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<span className='font-medium text-xs'>MAX mode</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type='button'
className='h-3.5 w-3.5 rounded text-muted-foreground transition-colors hover:text-foreground'
aria-label='MAX mode info'
>
<Info className='h-3.5 w-3.5' />
</button>
</TooltipTrigger>
<TooltipContent
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Significantly increases depth of reasoning
<br />
<span className='text-[10px] text-muted-foreground italic'>
Only available for advanced models
</span>
</TooltipContent>
</Tooltip>
</div>
<Switch
checked={!agentPrefetch}
disabled={['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel)}
title={
['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel)
? 'MAX mode is only available for advanced models'
: undefined
}
onCheckedChange={(checked) => {
if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel))
return
setAgentPrefetch(!checked)
}}
/>
</div>
<div className='my-1.5 flex justify-center'>
<div className='h-px w-[100%] bg-border' />
</div>
</div>
<div className='max-h-[280px] overflow-y-auto px-2 pb-2'>
<div>
<div className='mb-1'>
<span className='font-medium text-xs'>Model</span>
</div>
<div className='space-y-2'>
{/* Helper function to get icon for a model */}
{(() => {
const getModelIcon = (modelValue: string) => {
if (
['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(modelValue)
) {
return (
<BrainCircuit className='h-3 w-3 text-muted-foreground' />
)
}
if (
[
'gpt-5',
'gpt-5-medium',
'claude-4-sonnet',
'claude-4.5-sonnet',
].includes(modelValue)
) {
return <Brain className='h-3 w-3 text-muted-foreground' />
}
if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(modelValue)) {
return <Zap className='h-3 w-3 text-muted-foreground' />
}
return <div className='h-3 w-3' />
}
{(() => {
const isBrainModel = [
'gpt-5',
'gpt-5-medium',
'claude-4-sonnet',
'claude-4.5-sonnet',
].includes(selectedModel)
const isBrainCircuitModel = ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(
selectedModel
)
const showPurple = (isBrainModel || isBrainCircuitModel) && !agentPrefetch
const renderModelOption = (
option: (typeof modelOptions)[number]
) => (
<DropdownMenuItem
key={option.value}
onSelect={() => {
setSelectedModel(option.value)
// Automatically turn off max mode for fast models (Zap icon)
if (
['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(
option.value
) &&
!agentPrefetch
) {
setAgentPrefetch(true)
}
}}
className={cn(
'flex h-7 items-center gap-1.5 px-2 py-1 text-left text-xs',
selectedModel === option.value ? 'bg-muted/50' : ''
)}
>
{getModelIcon(option.value)}
<span>{option.label}</span>
</DropdownMenuItem>
)
return (
<DropdownMenu
onOpenChange={(open) => {
if (open) {
fetchEnabledModelsOnce()
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className={cn(
'flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs focus-visible:ring-0 focus-visible:ring-offset-0',
showPurple
? 'border-[var(--brand-primary-hover-hex)] text-[var(--brand-primary-hover-hex)] hover:bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_8%,transparent)] hover:text-[var(--brand-primary-hover-hex)]'
: 'border-border text-foreground'
)}
title='Choose mode'
>
{getModelIcon()}
<span className={cn(panelWidth < 360 ? 'max-w-[72px] truncate' : '')}>
{getCollapsedModeLabel()}
{agentPrefetch &&
!['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel) && (
<span className='ml-1 font-semibold'>Lite</span>
)}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='top' className='max-h-[400px] p-0'>
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
<div className='w-[220px]'>
<div className='max-h-[280px] overflow-y-auto p-2'>
<div>
<div className='mb-1'>
<span className='font-medium text-xs'>Model</span>
</div>
<div className='space-y-2'>
{/* Helper function to get icon for a model */}
{(() => {
const getModelIcon = (modelValue: string) => {
if (
['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(modelValue)
) {
return (
<BrainCircuit className='h-3 w-3 text-muted-foreground' />
)
}
if (
[
'gpt-5',
'gpt-5-medium',
'claude-4-sonnet',
'claude-4.5-sonnet',
].includes(modelValue)
) {
return <Brain className='h-3 w-3 text-muted-foreground' />
}
if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(modelValue)) {
return <Zap className='h-3 w-3 text-muted-foreground' />
}
return <div className='h-3 w-3' />
}
return (
<>
{/* OpenAI Models */}
<div>
<div className='px-2 py-1 font-medium text-[10px] text-muted-foreground uppercase'>
OpenAI
</div>
<div className='space-y-0.5'>
{modelOptions
.filter((option) =>
[
'gpt-5-fast',
'gpt-5',
'gpt-5-medium',
'gpt-5-high',
'gpt-4o',
'gpt-4.1',
'o3',
].includes(option.value)
)
.map(renderModelOption)}
</div>
</div>
const renderModelOption = (
option: (typeof modelOptions)[number]
) => (
<DropdownMenuItem
key={option.value}
onSelect={() => {
setSelectedModel(option.value)
// Automatically turn off Lite mode for fast models (Zap icon)
if (
['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(
option.value
) &&
agentPrefetch
) {
setAgentPrefetch(false)
}
}}
className={cn(
'flex h-7 items-center gap-1.5 px-2 py-1 text-left text-xs',
selectedModel === option.value ? 'bg-muted/50' : ''
)}
>
{getModelIcon(option.value)}
<span>{option.label}</span>
</DropdownMenuItem>
)
{/* Anthropic Models */}
<div>
<div className='px-2 py-1 font-medium text-[10px] text-muted-foreground uppercase'>
Anthropic
return (
<>
{/* OpenAI Models */}
<div>
<div className='px-2 py-1 font-medium text-[10px] text-muted-foreground uppercase'>
OpenAI
</div>
<div className='space-y-0.5'>
{modelOptions
.filter((option) =>
[
'gpt-5-fast',
'gpt-5',
'gpt-5-medium',
'gpt-5-high',
'gpt-4o',
'gpt-4.1',
'o3',
].includes(option.value)
)
.map(renderModelOption)}
</div>
</div>
<div className='space-y-0.5'>
{modelOptions
.filter((option) =>
[
'claude-4-sonnet',
'claude-4.5-sonnet',
'claude-4.1-opus',
].includes(option.value)
)
.map(renderModelOption)}
{/* Anthropic Models */}
<div>
<div className='px-2 py-1 font-medium text-[10px] text-muted-foreground uppercase'>
Anthropic
</div>
<div className='space-y-0.5'>
{modelOptions
.filter((option) =>
[
'claude-4-sonnet',
'claude-4.5-sonnet',
'claude-4.1-opus',
].includes(option.value)
)
.map(renderModelOption)}
</div>
</div>
</div>
</>
)
})()}
{/* More Models Button (only for hosted) */}
{isHosted && (
<div className='mt-1 border-t pt-1'>
<button
type='button'
onClick={() => {
// Dispatch event to open settings modal on copilot tab
window.dispatchEvent(
new CustomEvent('open-settings', {
detail: { tab: 'copilot' },
})
)
}}
className='w-full rounded-sm px-2 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-muted/50'
>
More Models...
</button>
</div>
)}
</>
)
})()}
</div>
</div>
</div>
</div>
</div>
</TooltipProvider>
</DropdownMenuContent>
</DropdownMenu>
}
</TooltipProvider>
</DropdownMenuContent>
</DropdownMenu>
)
})()}
<Button
variant='ghost'
size='icon'

View File

@@ -440,6 +440,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
onModeChange={setMode}
value={inputValue}
onChange={setInputValue}
panelWidth={panelWidth}
/>
)}
</>

View File

@@ -155,60 +155,30 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
workspaceId,
})
// Load the imported workflow state into stores immediately (optimistic update)
const { useWorkflowStore } = await import('@/stores/workflows/workflow/store')
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
// Set the workflow as active in the registry to prevent reload
useWorkflowRegistry.setState({ activeWorkflowId: newWorkflowId })
// Set the workflow state immediately
useWorkflowStore.setState({
blocks: workflowData.blocks || {},
edges: workflowData.edges || [],
loops: workflowData.loops || {},
parallels: workflowData.parallels || {},
lastSaved: Date.now(),
})
// Initialize subblock store with the imported blocks
useSubBlockStore.getState().initializeFromWorkflow(newWorkflowId, workflowData.blocks || {})
// Also set subblock values if they exist in the imported data
const subBlockStore = useSubBlockStore.getState()
Object.entries(workflowData.blocks).forEach(([blockId, block]: [string, any]) => {
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
subBlockStore.setValue(blockId, subBlockId, subBlock.value)
}
})
}
})
// Navigate to the new workflow after setting state
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
logger.info('Workflow imported successfully from JSON')
// Save to database in the background (fire and forget)
fetch(`/api/workflows/${newWorkflowId}/state`, {
// Save workflow state to database first
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workflowData),
})
.then((response) => {
if (!response.ok) {
logger.error('Failed to persist imported workflow to database')
} else {
logger.info('Imported workflow persisted to database')
}
})
.catch((error) => {
logger.error('Failed to persist imported workflow:', error)
})
if (!response.ok) {
logger.error('Failed to persist imported workflow to database')
throw new Error('Failed to save workflow')
}
logger.info('Imported workflow persisted to database')
// Pre-load the workflow state before navigating
const { setActiveWorkflow } = useWorkflowRegistry.getState()
await setActiveWorkflow(newWorkflowId)
// Navigate to the new workflow (replace to avoid history entry)
router.replace(`/workspace/${workspaceId}/w/${newWorkflowId}`)
logger.info('Workflow imported successfully from JSON')
} catch (error) {
logger.error('Failed to import workflow:', { error })
} finally {

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, Copy, Plus, Search } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Brain, BrainCircuit, Check, Copy, Plus, Zap } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -10,11 +10,12 @@ import {
AlertDialogHeader,
AlertDialogTitle,
Button,
Input,
Label,
Skeleton,
Switch,
} from '@/components/ui'
import { isHosted } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { useCopilotStore } from '@/stores/copilot/store'
const logger = createLogger('CopilotSettings')
@@ -23,26 +24,78 @@ interface CopilotKey {
displayKey: string
}
interface ModelOption {
value: string
label: string
icon: 'brain' | 'brainCircuit' | 'zap'
}
const OPENAI_MODELS: ModelOption[] = [
// Zap models first
{ value: 'gpt-4o', label: 'gpt-4o', icon: 'zap' },
{ value: 'gpt-4.1', label: 'gpt-4.1', icon: 'zap' },
{ value: 'gpt-5-fast', label: 'gpt-5-fast', icon: 'zap' },
// Brain models
{ value: 'gpt-5', label: 'gpt-5', icon: 'brain' },
{ value: 'gpt-5-medium', label: 'gpt-5-medium', icon: 'brain' },
// BrainCircuit models
{ value: 'gpt-5-high', label: 'gpt-5-high', icon: 'brainCircuit' },
{ value: 'o3', label: 'o3', icon: 'brainCircuit' },
]
const ANTHROPIC_MODELS: ModelOption[] = [
// Brain models
{ value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' },
{ value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet', icon: 'brain' },
// BrainCircuit models
{ value: 'claude-4.1-opus', label: 'claude-4.1-opus', icon: 'brainCircuit' },
]
const ALL_MODELS: ModelOption[] = [...OPENAI_MODELS, ...ANTHROPIC_MODELS]
// Default enabled/disabled state for all models
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
'gpt-4o': false,
'gpt-4.1': false,
'gpt-5-fast': false,
'gpt-5': true,
'gpt-5-medium': true,
'gpt-5-high': false,
o3: true,
'claude-4-sonnet': true,
'claude-4.5-sonnet': true,
'claude-4.1-opus': true,
}
const getModelIcon = (iconType: 'brain' | 'brainCircuit' | 'zap') => {
switch (iconType) {
case 'brainCircuit':
return <BrainCircuit className='h-3.5 w-3.5 text-muted-foreground' />
case 'brain':
return <Brain className='h-3.5 w-3.5 text-muted-foreground' />
case 'zap':
return <Zap className='h-3.5 w-3.5 text-muted-foreground' />
}
}
export function Copilot() {
const [keys, setKeys] = useState<CopilotKey[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [enabledModelsMap, setEnabledModelsMap] = useState<Record<string, boolean>>({})
const [isModelsLoading, setIsModelsLoading] = useState(true)
const hasFetchedModels = useRef(false)
const { setEnabledModels: setStoreEnabledModels } = useCopilotStore()
// Create flow state
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [newKey, setNewKey] = useState<string | null>(null)
const [isCreatingKey] = useState(false)
const [newKeyCopySuccess, setNewKeyCopySuccess] = useState(false)
// Delete flow state
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
// Filter keys based on search term (by masked display value)
const filteredKeys = keys.filter((key) =>
key.displayKey.toLowerCase().includes(searchTerm.toLowerCase())
)
const fetchKeys = useCallback(async () => {
try {
setIsLoading(true)
@@ -58,9 +111,41 @@ export function Copilot() {
}
}, [])
const fetchEnabledModels = useCallback(async () => {
if (hasFetchedModels.current) return
hasFetchedModels.current = true
try {
setIsModelsLoading(true)
const res = await fetch('/api/copilot/user-models')
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
const data = await res.json()
const modelsMap = data.enabledModels || DEFAULT_ENABLED_MODELS
setEnabledModelsMap(modelsMap)
// Convert to array for store (API already merged with defaults)
const enabledArray = Object.entries(modelsMap)
.filter(([_, enabled]) => enabled)
.map(([modelId]) => modelId)
setStoreEnabledModels(enabledArray)
} catch (error) {
logger.error('Failed to fetch enabled models', { error })
setEnabledModelsMap(DEFAULT_ENABLED_MODELS)
setStoreEnabledModels(
Object.keys(DEFAULT_ENABLED_MODELS).filter((key) => DEFAULT_ENABLED_MODELS[key])
)
} finally {
setIsModelsLoading(false)
}
}, [setStoreEnabledModels])
useEffect(() => {
fetchKeys()
}, [fetchKeys])
if (isHosted) {
fetchKeys()
}
fetchEnabledModels()
}, [])
const onGenerate = async () => {
try {
@@ -102,63 +187,97 @@ export function Copilot() {
}
}
const onCopy = async (value: string, keyId?: string) => {
const onCopy = async (value: string) => {
try {
await navigator.clipboard.writeText(value)
if (!keyId) {
setNewKeyCopySuccess(true)
setTimeout(() => setNewKeyCopySuccess(false), 1500)
}
setNewKeyCopySuccess(true)
setTimeout(() => setNewKeyCopySuccess(false), 1500)
} catch (error) {
logger.error('Copy failed', { error })
}
}
const toggleModel = async (modelValue: string, enabled: boolean) => {
const newModelsMap = { ...enabledModelsMap, [modelValue]: enabled }
setEnabledModelsMap(newModelsMap)
// Convert to array for store
const enabledArray = Object.entries(newModelsMap)
.filter(([_, isEnabled]) => isEnabled)
.map(([modelId]) => modelId)
setStoreEnabledModels(enabledArray)
try {
const res = await fetch('/api/copilot/user-models', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabledModels: newModelsMap }),
})
if (!res.ok) {
throw new Error('Failed to update models')
}
} catch (error) {
logger.error('Failed to update enabled models', { error })
// Revert on error
setEnabledModelsMap(enabledModelsMap)
const revertedArray = Object.entries(enabledModelsMap)
.filter(([_, isEnabled]) => isEnabled)
.map(([modelId]) => modelId)
setStoreEnabledModels(revertedArray)
}
}
const enabledCount = Object.values(enabledModelsMap).filter(Boolean).length
const totalCount = ALL_MODELS.length
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
)}
</div>
{/* Sticky Header with API Keys (only for hosted) */}
{isHosted && (
<div className='sticky top-0 z-10 border-b bg-background px-6 py-4'>
<div className='space-y-3'>
{/* API Keys Header */}
<div className='flex items-center justify-between'>
<div>
<h3 className='font-semibold text-foreground text-sm'>API Keys</h3>
<p className='text-muted-foreground text-xs'>
Generate keys for programmatic access
</p>
</div>
<Button
onClick={onGenerate}
variant='ghost'
size='sm'
className='h-8 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
>
<Plus className='h-3.5 w-3.5 stroke-[2px]' />
Create
</Button>
</div>
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
{isLoading ? (
{/* API Keys List */}
<div className='space-y-2'>
<CopilotKeySkeleton />
<CopilotKeySkeleton />
<CopilotKeySkeleton />
</div>
) : keys.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Generate Key" below to get started
</div>
) : (
<div className='space-y-2'>
{filteredKeys.map((k) => (
<div key={k.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
Copilot API Key
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>{k.displayKey}</code>
</div>
{isLoading ? (
<>
<CopilotKeySkeleton />
<CopilotKeySkeleton />
</>
) : keys.length === 0 ? (
<div className='py-3 text-center text-muted-foreground text-xs'>
No API keys yet
</div>
) : (
keys.map((k) => (
<div
key={k.id}
className='flex items-center justify-between gap-4 rounded-lg border bg-muted/30 px-3 py-2'
>
<div className='flex min-w-0 items-center gap-3'>
<code className='truncate font-mono text-foreground text-xs'>
{k.displayKey}
</code>
</div>
<Button
@@ -168,44 +287,103 @@ export function Copilot() {
setDeleteKey(k)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
className='h-7 flex-shrink-0 text-muted-foreground text-xs hover:text-foreground'
>
Delete
</Button>
</div>
</div>
))}
{/* Show message when search has no results but there are keys */}
{searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No API keys found matching "{searchTerm}"
</div>
))
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[108px]' />
</>
{/* Scrollable Content - Models Section */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent flex-1 overflow-y-auto px-6 py-4'>
<div className='space-y-3'>
{/* Models Header */}
<div>
<h3 className='font-semibold text-foreground text-sm'>Models</h3>
<div className='text-muted-foreground text-xs'>
{isModelsLoading ? (
<Skeleton className='mt-0.5 h-3 w-32' />
) : (
<span>
{enabledCount} of {totalCount} enabled
</span>
)}
</div>
</div>
{/* Models List */}
{isModelsLoading ? (
<div className='space-y-2'>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className='flex items-center justify-between py-1.5'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-9 rounded-full' />
</div>
))}
</div>
) : (
<>
<Button
onClick={onGenerate}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
</Button>
</>
<div className='space-y-4'>
{/* OpenAI Models */}
<div>
<div className='mb-2 px-2 font-medium text-[10px] text-muted-foreground uppercase'>
OpenAI
</div>
<div className='space-y-1'>
{OPENAI_MODELS.map((model) => {
const isEnabled = enabledModelsMap[model.value] ?? false
return (
<div
key={model.value}
className='-mx-2 flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/50'
>
<div className='flex items-center gap-2'>
{getModelIcon(model.icon)}
<span className='text-foreground text-sm'>{model.label}</span>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => toggleModel(model.value, checked)}
className='scale-90'
/>
</div>
)
})}
</div>
</div>
{/* Anthropic Models */}
<div>
<div className='mb-2 px-2 font-medium text-[10px] text-muted-foreground uppercase'>
Anthropic
</div>
<div className='space-y-1'>
{ANTHROPIC_MODELS.map((model) => {
const isEnabled = enabledModelsMap[model.value] ?? false
return (
<div
key={model.value}
className='-mx-2 flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/50'
>
<div className='flex items-center gap-2'>
{getModelIcon(model.icon)}
<span className='text-foreground text-sm'>{model.label}</span>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => toggleModel(model.value, checked)}
className='scale-90'
/>
</div>
)
})}
</div>
</div>
</div>
)}
</div>
</div>
@@ -292,15 +470,9 @@ export function Copilot() {
function CopilotKeySkeleton() {
return (
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' />
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-20 rounded-[8px]' />
<Skeleton className='h-4 w-24' />
</div>
<Skeleton className='h-8 w-16' />
</div>
<div className='flex items-center justify-between gap-4 rounded-lg border bg-muted/30 px-3 py-2'>
<Skeleton className='h-4 w-48' />
<Skeleton className='h-7 w-14' />
</div>
)
}

View File

@@ -96,7 +96,7 @@ const allNavigationItems: NavigationItem[] = [
},
{
id: 'copilot',
label: 'Copilot Keys',
label: 'Copilot',
icon: Bot,
},
{
@@ -163,9 +163,6 @@ export function SettingsNavigation({
}, [userId, isHosted])
const navigationItems = allNavigationItems.filter((item) => {
if (item.id === 'copilot' && !isHosted) {
return false
}
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
return false
}

View File

@@ -3,7 +3,6 @@
import { useEffect, useRef, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { isHosted } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import {
Account,
@@ -181,7 +180,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<SSO />
</div>
)}
{isHosted && activeSection === 'copilot' && (
{activeSection === 'copilot' && (
<div className='h-full'>
<Copilot />
</div>

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
import type { Edge } from 'reactflow'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlockOutputs } from '@/lib/workflows/block-outputs'
import { getBlock } from '@/blocks'
import { resolveOutputType } from '@/blocks/utils'
import { useSocket } from '@/contexts/socket-context'
@@ -761,7 +762,11 @@ export function useCollaborativeWorkflow() {
})
}
const outputs = resolveOutputType(blockConfig.outputs)
// Get outputs based on trigger mode
const isTriggerMode = triggerMode || false
const outputs = isTriggerMode
? getBlockOutputs(type, subBlocks, isTriggerMode)
: resolveOutputType(blockConfig.outputs)
const completeBlockData = {
id,
@@ -775,7 +780,7 @@ export function useCollaborativeWorkflow() {
horizontalHandles: true,
isWide: false,
advancedMode: false,
triggerMode: triggerMode || false,
triggerMode: isTriggerMode,
height: 0, // Default height, will be set by the UI
parentId,
extent,

View File

@@ -0,0 +1,31 @@
import { Loader2, MinusCircle, XCircle, Zap } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetOperationsExamplesClientTool extends BaseClientTool {
static readonly id = 'get_operations_examples'
constructor(toolCallId: string) {
super(toolCallId, GetOperationsExamplesClientTool.id, GetOperationsExamplesClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Selecting an operation', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Selecting an operation', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Selecting an operation', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Selected an operation', icon: Zap },
[ClientToolCallState.error]: { text: 'Failed to select an operation', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted selecting an operation', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped selecting an operation', icon: MinusCircle },
},
interrupt: undefined,
}
async execute(): Promise<void> {
return
}
}

View File

@@ -4,6 +4,7 @@ import { workflow as workflowTable } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlockOutputs } from '@/lib/workflows/block-outputs'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { validateWorkflowState } from '@/lib/workflows/validation'
import { getAllBlocks } from '@/blocks/registry'
@@ -22,12 +23,123 @@ interface EditWorkflowParams {
currentUserWorkflow?: string
}
/**
* Topologically sort insert operations to ensure parents are created before children
* Returns sorted array where parent inserts always come before child inserts
*/
function topologicalSortInserts(
inserts: EditWorkflowOperation[],
adds: EditWorkflowOperation[]
): EditWorkflowOperation[] {
if (inserts.length === 0) return []
// Build a map of blockId -> operation for quick lookup
const insertMap = new Map<string, EditWorkflowOperation>()
inserts.forEach((op) => insertMap.set(op.block_id, op))
// Build a set of blocks being added (potential parents)
const addedBlocks = new Set(adds.map((op) => op.block_id))
// Build dependency graph: block -> blocks that depend on it
const dependents = new Map<string, Set<string>>()
const dependencies = new Map<string, Set<string>>()
inserts.forEach((op) => {
const blockId = op.block_id
const parentId = op.params?.subflowId
dependencies.set(blockId, new Set())
if (parentId) {
// Track dependency if parent is being inserted OR being added
// This ensures children wait for parents regardless of operation type
const parentBeingCreated = insertMap.has(parentId) || addedBlocks.has(parentId)
if (parentBeingCreated) {
// Only add dependency if parent is also being inserted (not added)
// Because adds run before inserts, added parents are already created
if (insertMap.has(parentId)) {
dependencies.get(blockId)!.add(parentId)
if (!dependents.has(parentId)) {
dependents.set(parentId, new Set())
}
dependents.get(parentId)!.add(blockId)
}
}
}
})
// Topological sort using Kahn's algorithm
const sorted: EditWorkflowOperation[] = []
const queue: string[] = []
// Start with nodes that have no dependencies (or depend only on added blocks)
inserts.forEach((op) => {
const deps = dependencies.get(op.block_id)!
if (deps.size === 0) {
queue.push(op.block_id)
}
})
while (queue.length > 0) {
const blockId = queue.shift()!
const op = insertMap.get(blockId)
if (op) {
sorted.push(op)
}
// Remove this node from dependencies of others
const children = dependents.get(blockId)
if (children) {
children.forEach((childId) => {
const childDeps = dependencies.get(childId)!
childDeps.delete(blockId)
if (childDeps.size === 0) {
queue.push(childId)
}
})
}
}
// If sorted length doesn't match input, there's a cycle (shouldn't happen with valid operations)
// Just append remaining operations
if (sorted.length < inserts.length) {
inserts.forEach((op) => {
if (!sorted.includes(op)) {
sorted.push(op)
}
})
}
return sorted
}
/**
* Helper to create a block state from operation params
*/
function createBlockFromParams(blockId: string, params: any, parentId?: string): any {
const blockConfig = getAllBlocks().find((b) => b.type === params.type)
// Determine outputs based on trigger mode
const triggerMode = params.triggerMode || false
let outputs: Record<string, any>
if (params.outputs) {
outputs = params.outputs
} else if (blockConfig) {
const subBlocks: Record<string, any> = {}
if (params.inputs) {
Object.entries(params.inputs).forEach(([key, value]) => {
subBlocks[key] = { id: key, type: 'short-input', value: value }
})
}
outputs = triggerMode
? getBlockOutputs(params.type, subBlocks, triggerMode)
: resolveOutputType(blockConfig.outputs)
} else {
outputs = {}
}
const blockState: any = {
id: blockId,
type: params.type,
@@ -38,19 +150,39 @@ function createBlockFromParams(blockId: string, params: any, parentId?: string):
isWide: false,
advancedMode: params.advancedMode || false,
height: 0,
triggerMode: params.triggerMode || false,
triggerMode: triggerMode,
subBlocks: {},
outputs: params.outputs || (blockConfig ? resolveOutputType(blockConfig.outputs) : {}),
outputs: outputs,
data: parentId ? { parentId, extent: 'parent' as const } : {},
}
// Add inputs as subBlocks
if (params.inputs) {
Object.entries(params.inputs).forEach(([key, value]) => {
let sanitizedValue = value
// Special handling for inputFormat - ensure it's an array
if (key === 'inputFormat' && value !== null && value !== undefined) {
if (!Array.isArray(value)) {
// Invalid format, default to empty array
sanitizedValue = []
}
}
// Special handling for tools - normalize to restore sanitized fields
if (key === 'tools' && Array.isArray(value)) {
sanitizedValue = normalizeTools(value)
}
// Special handling for responseFormat - normalize to ensure consistent format
if (key === 'responseFormat' && value) {
sanitizedValue = normalizeResponseFormat(value)
}
blockState.subBlocks[key] = {
id: key,
type: 'short-input',
value: value,
value: sanitizedValue,
}
})
}
@@ -71,6 +203,90 @@ function createBlockFromParams(blockId: string, params: any, parentId?: string):
return blockState
}
/**
* Normalize tools array by adding back fields that were sanitized for training
*/
function normalizeTools(tools: any[]): any[] {
return tools.map((tool) => {
if (tool.type === 'custom-tool') {
// Reconstruct sanitized custom tool fields
const normalized: any = {
...tool,
params: tool.params || {},
isExpanded: tool.isExpanded ?? true,
}
// Ensure schema has proper structure
if (normalized.schema?.function) {
normalized.schema = {
type: 'function',
function: {
name: tool.title, // Derive name from title
description: normalized.schema.function.description,
parameters: normalized.schema.function.parameters,
},
}
}
return normalized
}
// For other tool types, just ensure isExpanded exists
return {
...tool,
isExpanded: tool.isExpanded ?? true,
}
})
}
/**
* Normalize responseFormat to ensure consistent storage
* Handles both string (JSON) and object formats
* Returns pretty-printed JSON for better UI readability
*/
function normalizeResponseFormat(value: any): string {
try {
let obj = value
// If it's already a string, parse it first
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
obj = JSON.parse(trimmed)
}
// If it's an object, stringify it with consistent formatting
if (obj && typeof obj === 'object') {
// Sort keys recursively for consistent comparison
const sortKeys = (item: any): any => {
if (Array.isArray(item)) {
return item.map(sortKeys)
}
if (item !== null && typeof item === 'object') {
return Object.keys(item)
.sort()
.reduce((result: any, key: string) => {
result[key] = sortKeys(item[key])
return result
}, {})
}
return item
}
// Return pretty-printed with 2-space indentation for UI readability
// The sanitizer will normalize it to minified format for comparison
return JSON.stringify(sortKeys(obj), null, 2)
}
return String(value)
} catch (error) {
// If parsing fails, return the original value as string
return String(value)
}
}
/**
* Helper to add connections as edges for a block
*/
@@ -106,13 +322,13 @@ function applyOperationsToWorkflowState(
// Log initial state
const logger = createLogger('EditWorkflowServerTool')
logger.debug('Initial blocks before operations:', {
blockCount: Object.keys(modifiedState.blocks || {}).length,
blockTypes: Object.entries(modifiedState.blocks || {}).map(([id, block]: [string, any]) => ({
id,
type: block.type,
hasType: block.type !== undefined,
})),
logger.info('Applying operations to workflow:', {
totalOperations: operations.length,
operationTypes: operations.reduce((acc: any, op) => {
acc[op.operation_type] = (acc[op.operation_type] || 0) + 1
return acc
}, {}),
initialBlockCount: Object.keys(modifiedState.blocks || {}).length,
})
// Reorder operations: delete -> extract -> add -> insert -> edit
@@ -121,17 +337,34 @@ function applyOperationsToWorkflowState(
const adds = operations.filter((op) => op.operation_type === 'add')
const inserts = operations.filter((op) => op.operation_type === 'insert_into_subflow')
const edits = operations.filter((op) => op.operation_type === 'edit')
// Sort insert operations to ensure parents are inserted before children
// This handles cases where a loop/parallel is being added along with its children
const sortedInserts = topologicalSortInserts(inserts, adds)
const orderedOperations: EditWorkflowOperation[] = [
...deletes,
...extracts,
...adds,
...inserts,
...sortedInserts,
...edits,
]
logger.info('Operations after reordering:', {
order: orderedOperations.map(
(op) =>
`${op.operation_type}:${op.block_id}${op.params?.subflowId ? `(parent:${op.params.subflowId})` : ''}`
),
})
for (const operation of orderedOperations) {
const { operation_type, block_id, params } = operation
logger.debug(`Executing operation: ${operation_type} for block ${block_id}`, {
params: params ? Object.keys(params) : [],
currentBlockCount: Object.keys(modifiedState.blocks).length,
})
switch (operation_type) {
case 'delete': {
if (modifiedState.blocks[block_id]) {
@@ -175,14 +408,34 @@ function applyOperationsToWorkflowState(
if (params?.inputs) {
if (!block.subBlocks) block.subBlocks = {}
Object.entries(params.inputs).forEach(([key, value]) => {
let sanitizedValue = value
// Special handling for inputFormat - ensure it's an array
if (key === 'inputFormat' && value !== null && value !== undefined) {
if (!Array.isArray(value)) {
// Invalid format, default to empty array
sanitizedValue = []
}
}
// Special handling for tools - normalize to restore sanitized fields
if (key === 'tools' && Array.isArray(value)) {
sanitizedValue = normalizeTools(value)
}
// Special handling for responseFormat - normalize to ensure consistent format
if (key === 'responseFormat' && value) {
sanitizedValue = normalizeResponseFormat(value)
}
if (!block.subBlocks[key]) {
block.subBlocks[key] = {
id: key,
type: 'short-input',
value: value,
value: sanitizedValue,
}
} else {
block.subBlocks[key].value = value
block.subBlocks[key].value = sanitizedValue
}
})
@@ -335,18 +588,8 @@ function applyOperationsToWorkflowState(
// Create new block with proper structure
const newBlock = createBlockFromParams(block_id, params)
// Handle nested nodes (for loops/parallels created from scratch)
// Set loop/parallel data on parent block BEFORE adding to blocks
if (params.nestedNodes) {
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
const childBlockState = createBlockFromParams(childId, childBlock, block_id)
modifiedState.blocks[childId] = childBlockState
if (childBlock.connections) {
addConnectionsAsEdges(modifiedState, childId, childBlock.connections)
}
})
// Set loop/parallel data on parent block
if (params.type === 'loop') {
newBlock.data = {
...newBlock.data,
@@ -364,8 +607,22 @@ function applyOperationsToWorkflowState(
}
}
// Add parent block FIRST before adding children
// This ensures children can reference valid parentId
modifiedState.blocks[block_id] = newBlock
// Handle nested nodes (for loops/parallels created from scratch)
if (params.nestedNodes) {
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
const childBlockState = createBlockFromParams(childId, childBlock, block_id)
modifiedState.blocks[childId] = childBlockState
if (childBlock.connections) {
addConnectionsAsEdges(modifiedState, childId, childBlock.connections)
}
})
}
// Add connections as edges
if (params.connections) {
addConnectionsAsEdges(modifiedState, block_id, params.connections)
@@ -377,15 +634,28 @@ function applyOperationsToWorkflowState(
case 'insert_into_subflow': {
const subflowId = params?.subflowId
if (!subflowId || !params?.type || !params?.name) {
logger.warn('Missing required params for insert_into_subflow', { block_id, params })
logger.error('Missing required params for insert_into_subflow', { block_id, params })
break
}
const subflowBlock = modifiedState.blocks[subflowId]
if (!subflowBlock || (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel')) {
logger.warn('Subflow block not found or invalid type', {
if (!subflowBlock) {
logger.error('Subflow block not found - parent must be created first', {
subflowId,
type: subflowBlock?.type,
block_id,
existingBlocks: Object.keys(modifiedState.blocks),
operationType: 'insert_into_subflow',
})
// This is a critical error - the operation ordering is wrong
// Skip this operation but don't break the entire workflow
break
}
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
logger.error('Subflow block has invalid type', {
subflowId,
type: subflowBlock.type,
block_id,
})
break
}
@@ -407,10 +677,32 @@ function applyOperationsToWorkflowState(
// Update inputs if provided
if (params.inputs) {
Object.entries(params.inputs).forEach(([key, value]) => {
let sanitizedValue = value
if (key === 'inputFormat' && value !== null && value !== undefined) {
if (!Array.isArray(value)) {
sanitizedValue = []
}
}
// Special handling for tools - normalize to restore sanitized fields
if (key === 'tools' && Array.isArray(value)) {
sanitizedValue = normalizeTools(value)
}
// Special handling for responseFormat - normalize to ensure consistent format
if (key === 'responseFormat' && value) {
sanitizedValue = normalizeResponseFormat(value)
}
if (!existingBlock.subBlocks[key]) {
existingBlock.subBlocks[key] = { id: key, type: 'short-input', value }
existingBlock.subBlocks[key] = {
id: key,
type: 'short-input',
value: sanitizedValue,
}
} else {
existingBlock.subBlocks[key].value = value
existingBlock.subBlocks[key].value = sanitizedValue
}
})
}

View File

@@ -1,2 +1,2 @@
export const SIM_AGENT_API_URL_DEFAULT = 'https://copilot.sim.ai'
export const SIM_AGENT_VERSION = '1.0.0'
export const SIM_AGENT_VERSION = '1.0.1'

View File

@@ -1,16 +1,30 @@
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
/**
* Get the effective outputs for a block, including dynamic outputs from inputFormat
* and trigger outputs for blocks in trigger mode
*/
export function getBlockOutputs(
blockType: string,
subBlocks?: Record<string, any>
subBlocks?: Record<string, any>,
triggerMode?: boolean
): Record<string, any> {
const blockConfig = getBlock(blockType)
if (!blockConfig) return {}
// If block is in trigger mode, use trigger outputs instead of block outputs
if (triggerMode && blockConfig.triggers?.enabled) {
const triggerId = subBlocks?.triggerId?.value || blockConfig.triggers?.available?.[0]
if (triggerId) {
const trigger = getTrigger(triggerId)
if (trigger?.outputs) {
return trigger.outputs
}
}
}
// Start with the static outputs defined in the config
let outputs = { ...(blockConfig.outputs || {}) }
@@ -32,12 +46,20 @@ export function getBlockOutputs(
startWorkflowValue === 'manual'
) {
// API/manual mode - use inputFormat fields only
const inputFormatValue = subBlocks?.inputFormat?.value
let inputFormatValue = subBlocks?.inputFormat?.value
outputs = {}
if (
inputFormatValue !== null &&
inputFormatValue !== undefined &&
!Array.isArray(inputFormatValue)
) {
inputFormatValue = []
}
if (Array.isArray(inputFormatValue)) {
inputFormatValue.forEach((field: { name?: string; type?: string }) => {
if (field.name && field.name.trim() !== '') {
if (field?.name && field.name.trim() !== '') {
outputs[field.name] = {
type: (field.type || 'any') as any,
description: `Field from input format`,
@@ -52,7 +74,17 @@ export function getBlockOutputs(
// For blocks with inputFormat, add dynamic outputs
if (hasInputFormat(blockConfig) && subBlocks?.inputFormat?.value) {
const inputFormatValue = subBlocks.inputFormat.value
let inputFormatValue = subBlocks.inputFormat.value
// Sanitize inputFormat - ensure it's an array
if (
inputFormatValue !== null &&
inputFormatValue !== undefined &&
!Array.isArray(inputFormatValue)
) {
// Invalid format, default to empty array
inputFormatValue = []
}
if (Array.isArray(inputFormatValue)) {
// For API and Input triggers, only use inputFormat fields
@@ -61,7 +93,7 @@ export function getBlockOutputs(
// Add each field from inputFormat as an output at root level
inputFormatValue.forEach((field: { name?: string; type?: string }) => {
if (field.name && field.name.trim() !== '') {
if (field?.name && field.name.trim() !== '') {
outputs[field.name] = {
type: (field.type || 'any') as any,
description: `Field from input format`,
@@ -88,27 +120,66 @@ function hasInputFormat(blockConfig: BlockConfig): boolean {
/**
* Get output paths for a block (for tag dropdown)
*/
export function getBlockOutputPaths(blockType: string, subBlocks?: Record<string, any>): string[] {
const outputs = getBlockOutputs(blockType, subBlocks)
return Object.keys(outputs)
export function getBlockOutputPaths(
blockType: string,
subBlocks?: Record<string, any>,
triggerMode?: boolean
): string[] {
const outputs = getBlockOutputs(blockType, subBlocks, triggerMode)
// Recursively collect all paths from nested outputs
const paths: string[] = []
function collectPaths(obj: Record<string, any>, prefix = ''): void {
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
// If value has 'type' property, it's a leaf node (output definition)
if (value && typeof value === 'object' && 'type' in value) {
paths.push(path)
}
// If value is an object without 'type', recurse into it
else if (value && typeof value === 'object' && !Array.isArray(value)) {
collectPaths(value, path)
}
// Otherwise treat as a leaf node
else {
paths.push(path)
}
}
}
collectPaths(outputs)
return paths
}
/**
* Get the type of a specific output path
* Get the type of a specific output path (supports nested paths like "email.subject")
*/
export function getBlockOutputType(
blockType: string,
outputPath: string,
subBlocks?: Record<string, any>
subBlocks?: Record<string, any>,
triggerMode?: boolean
): string {
const outputs = getBlockOutputs(blockType, subBlocks)
const output = outputs[outputPath]
const outputs = getBlockOutputs(blockType, subBlocks, triggerMode)
if (!output) return 'any'
// Navigate through nested path
const pathParts = outputPath.split('.')
let current: any = outputs
if (typeof output === 'object' && 'type' in output) {
return output.type
for (const part of pathParts) {
if (!current || typeof current !== 'object') {
return 'any'
}
current = current[part]
}
return typeof output === 'string' ? output : 'any'
if (!current) return 'any'
if (typeof current === 'object' && 'type' in current) {
return current.type
}
return typeof current === 'string' ? current : 'any'
}

View File

@@ -349,6 +349,14 @@ export class WorkflowDiffEngine {
id: finalId,
}
// Update parentId in data if it exists and has been remapped
if (finalBlock.data?.parentId && idMap[finalBlock.data.parentId]) {
finalBlock.data = {
...finalBlock.data,
parentId: idMap[finalBlock.data.parentId],
}
}
finalBlocks[finalId] = finalBlock
}

View File

@@ -18,7 +18,7 @@ export interface CopilotWorkflowState {
export interface CopilotBlockState {
type: string
name: string
inputs?: Record<string, string | number | string[][]>
inputs?: Record<string, string | number | string[][] | object>
outputs: BlockState['outputs']
connections?: Record<string, string | string[]>
nestedNodes?: Record<string, CopilotBlockState>
@@ -83,17 +83,127 @@ function isSensitiveSubBlock(key: string, subBlock: BlockState['subBlocks'][stri
return false
}
/**
* Sanitize condition blocks by removing UI-specific metadata
* Returns cleaned JSON string (not parsed array)
*/
function sanitizeConditions(conditionsJson: string): string {
try {
const conditions = JSON.parse(conditionsJson)
if (!Array.isArray(conditions)) return conditionsJson
// Keep only id, title, and value - remove UI state
const cleaned = conditions.map((cond: any) => ({
id: cond.id,
title: cond.title,
value: cond.value || '',
}))
return JSON.stringify(cleaned)
} catch {
return conditionsJson
}
}
/**
* Sanitize tools array by removing UI state and redundant fields
*/
function sanitizeTools(tools: any[]): any[] {
return tools.map((tool) => {
if (tool.type === 'custom-tool') {
const sanitized: any = {
type: tool.type,
title: tool.title,
toolId: tool.toolId,
usageControl: tool.usageControl,
}
if (tool.schema?.function) {
sanitized.schema = {
function: {
description: tool.schema.function.description,
parameters: tool.schema.function.parameters,
},
}
}
if (tool.code) {
sanitized.code = tool.code
}
return sanitized
}
const { isExpanded, ...cleanTool } = tool
return cleanTool
})
}
/**
* Sanitize subblocks by removing null values, secrets, and simplifying structure
* Maps each subblock key directly to its value instead of the full object
* Note: responseFormat is kept as an object for better copilot understanding
*/
function sanitizeSubBlocks(
subBlocks: BlockState['subBlocks']
): Record<string, string | number | string[][]> {
const sanitized: Record<string, string | number | string[][]> = {}
): Record<string, string | number | string[][] | object> {
const sanitized: Record<string, string | number | string[][] | object> = {}
Object.entries(subBlocks).forEach(([key, subBlock]) => {
// Skip null/undefined values
// Special handling for responseFormat - process BEFORE null check
// so we can detect when it's added/removed
if (key === 'responseFormat') {
try {
// Handle null/undefined - skip if no value
if (subBlock.value === null || subBlock.value === undefined) {
return
}
let obj = subBlock.value
// Handle string values - parse them first
if (typeof subBlock.value === 'string') {
const trimmed = subBlock.value.trim()
if (!trimmed) {
// Empty string - skip this field
return
}
obj = JSON.parse(trimmed)
}
// Handle object values - normalize keys and keep as object for copilot
if (obj && typeof obj === 'object') {
// Sort keys recursively for consistent comparison
const sortKeys = (item: any): any => {
if (Array.isArray(item)) {
return item.map(sortKeys)
}
if (item !== null && typeof item === 'object') {
return Object.keys(item)
.sort()
.reduce((result: any, key: string) => {
result[key] = sortKeys(item[key])
return result
}, {})
}
return item
}
// Keep as object (not stringified) for better copilot understanding
const normalized = sortKeys(obj)
sanitized[key] = normalized
return
}
// If we get here, obj is not an object (maybe null or primitive) - skip it
return
} catch (error) {
// Invalid JSON - skip this field to avoid crashes
return
}
}
// Skip null/undefined values for other fields
if (subBlock.value === null || subBlock.value === undefined) {
return
}
@@ -112,36 +222,24 @@ function sanitizeSubBlocks(
return
}
// For non-sensitive, non-null values, include them
// Special handling for condition-input type - clean UI metadata
if (subBlock.type === 'condition-input' && typeof subBlock.value === 'string') {
const cleanedConditions: string = sanitizeConditions(subBlock.value)
sanitized[key] = cleanedConditions
return
}
if (key === 'tools' && Array.isArray(subBlock.value)) {
sanitized[key] = sanitizeTools(subBlock.value)
return
}
sanitized[key] = subBlock.value
})
return sanitized
}
/**
* Reconstruct full subBlock structure from simplified copilot format
* Uses existing block structure as template for id and type fields
*/
function reconstructSubBlocks(
simplifiedSubBlocks: Record<string, string | number | string[][]>,
existingSubBlocks?: BlockState['subBlocks']
): BlockState['subBlocks'] {
const reconstructed: BlockState['subBlocks'] = {}
Object.entries(simplifiedSubBlocks).forEach(([key, value]) => {
const existingSubBlock = existingSubBlocks?.[key]
reconstructed[key] = {
id: existingSubBlock?.id || key,
type: existingSubBlock?.type || 'short-input',
value,
}
})
return reconstructed
}
/**
* Extract connections for a block from edges and format as operations-style connections
*/
@@ -198,14 +296,16 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
const connections = extractConnectionsForBlock(blockId, state.edges)
// For loop/parallel blocks, extract config from block.data instead of subBlocks
let inputs: Record<string, string | number | string[][]> = {}
let inputs: Record<string, string | number | string[][] | object>
if (block.type === 'loop' || block.type === 'parallel') {
// Extract configuration from block.data
if (block.data?.loopType) inputs.loopType = block.data.loopType
if (block.data?.count !== undefined) inputs.iterations = block.data.count
if (block.data?.collection !== undefined) inputs.collection = block.data.collection
if (block.data?.parallelType) inputs.parallelType = block.data.parallelType
const loopInputs: Record<string, string | number | string[][] | object> = {}
if (block.data?.loopType) loopInputs.loopType = block.data.loopType
if (block.data?.count !== undefined) loopInputs.iterations = block.data.count
if (block.data?.collection !== undefined) loopInputs.collection = block.data.collection
if (block.data?.parallelType) loopInputs.parallelType = block.data.parallelType
inputs = loopInputs
} else {
// For regular blocks, sanitize subBlocks
inputs = sanitizeSubBlocks(block.subBlocks)
@@ -277,14 +377,10 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
Object.values(clonedState.blocks).forEach((block: any) => {
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
// Clear OAuth credentials and API keys using regex patterns
// Clear OAuth credentials and API keys based on field name only
if (
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) ||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(
subBlock.type || ''
) ||
(typeof subBlock.value === 'string' &&
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(subBlock.value))
subBlock.type === 'oauth-input'
) {
subBlock.value = ''
}

View File

@@ -174,6 +174,17 @@ export function computeEditSequence(
if (!(blockId in startFlattened)) {
const { block, parentId } = endFlattened[blockId]
if (parentId) {
// Check if this block will be included in parent's nestedNodes
const parentData = endFlattened[parentId]
const parentIsNew = parentData && !(parentId in startFlattened)
const parentHasNestedNodes = parentData?.block?.nestedNodes?.[blockId]
// Skip if parent is new and will include this block in nestedNodes
if (parentIsNew && parentHasNestedNodes) {
// Parent's 'add' operation will include this child, skip separate operation
continue
}
// Block was added inside a subflow - include full block state
const addParams: EditOperation['params'] = {
subflowId: parentId,
@@ -181,8 +192,14 @@ export function computeEditSequence(
name: block.name,
outputs: block.outputs,
enabled: block.enabled !== undefined ? block.enabled : true,
...(block?.triggerMode !== undefined && { triggerMode: Boolean(block.triggerMode) }),
...(block?.advancedMode !== undefined && { advancedMode: Boolean(block.advancedMode) }),
}
// Only include triggerMode/advancedMode if true
if (block?.triggerMode === true) {
addParams.triggerMode = true
}
if (block?.advancedMode === true) {
addParams.advancedMode = true
}
// Add inputs if present
@@ -208,8 +225,14 @@ export function computeEditSequence(
const addParams: EditOperation['params'] = {
type: block.type,
name: block.name,
...(block?.triggerMode !== undefined && { triggerMode: Boolean(block.triggerMode) }),
...(block?.advancedMode !== undefined && { advancedMode: Boolean(block.advancedMode) }),
}
if (block?.triggerMode === true) {
addParams.triggerMode = true
}
if (block?.advancedMode === true) {
addParams.advancedMode = true
}
// Add inputs if present
@@ -224,10 +247,18 @@ export function computeEditSequence(
addParams.connections = connections
}
// Add nested nodes if present (for loops/parallels created from scratch)
// Add nested nodes if present AND all children are new
// This creates the loop/parallel with children in one operation
// If some children already exist, they'll have separate insert_into_subflow operations
if (block.nestedNodes && Object.keys(block.nestedNodes).length > 0) {
addParams.nestedNodes = block.nestedNodes
subflowsChanged++
const allChildrenNew = Object.keys(block.nestedNodes).every(
(childId) => !(childId in startFlattened)
)
if (allChildrenNew) {
addParams.nestedNodes = block.nestedNodes
subflowsChanged++
}
}
operations.push({
@@ -266,12 +297,14 @@ export function computeEditSequence(
name: endBlock.name,
outputs: endBlock.outputs,
enabled: endBlock.enabled !== undefined ? endBlock.enabled : true,
...(endBlock?.triggerMode !== undefined && {
triggerMode: Boolean(endBlock.triggerMode),
}),
...(endBlock?.advancedMode !== undefined && {
advancedMode: Boolean(endBlock.advancedMode),
}),
}
// Only include triggerMode/advancedMode if true
if (endBlock?.triggerMode === true) {
addParams.triggerMode = true
}
if (endBlock?.advancedMode === true) {
addParams.advancedMode = true
}
const inputs = extractInputValues(endBlock)
@@ -436,12 +469,13 @@ function computeBlockChanges(
hasChanges = true
}
// Check input value changes
// Check input value changes - only include changed fields
const startInputs = extractInputValues(startBlock)
const endInputs = extractInputValues(endBlock)
if (JSON.stringify(startInputs) !== JSON.stringify(endInputs)) {
changes.inputs = endInputs
const changedInputs = computeInputDelta(startInputs, endInputs)
if (Object.keys(changedInputs).length > 0) {
changes.inputs = changedInputs
hasChanges = true
}
@@ -457,6 +491,28 @@ function computeBlockChanges(
return hasChanges ? changes : null
}
/**
* Compute delta between two input objects
* Only returns fields that actually changed or were added
*/
function computeInputDelta(
startInputs: Record<string, any>,
endInputs: Record<string, any>
): Record<string, any> {
const delta: Record<string, any> = {}
for (const key in endInputs) {
if (
!(key in startInputs) ||
JSON.stringify(startInputs[key]) !== JSON.stringify(endInputs[key])
) {
delta[key] = endInputs[key]
}
}
return delta
}
/**
* Format edit operations into a human-readable description
*/

View File

@@ -12,6 +12,7 @@ import { GetBlocksAndToolsClientTool } from '@/lib/copilot/tools/client/blocks/g
import { GetBlocksMetadataClientTool } from '@/lib/copilot/tools/client/blocks/get-blocks-metadata'
import { GetTriggerBlocksClientTool } from '@/lib/copilot/tools/client/blocks/get-trigger-blocks'
import { GetExamplesRagClientTool } from '@/lib/copilot/tools/client/examples/get-examples-rag'
import { GetOperationsExamplesClientTool } from '@/lib/copilot/tools/client/examples/get-operations-examples'
import { GetTriggerExamplesClientTool } from '@/lib/copilot/tools/client/examples/get-trigger-examples'
import { ListGDriveFilesClientTool } from '@/lib/copilot/tools/client/gdrive/list-files'
import { ReadGDriveFileClientTool } from '@/lib/copilot/tools/client/gdrive/read-file'
@@ -90,6 +91,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
set_global_workflow_variables: (id) => new SetGlobalWorkflowVariablesClientTool(id),
get_trigger_examples: (id) => new GetTriggerExamplesClientTool(id),
get_examples_rag: (id) => new GetExamplesRagClientTool(id),
get_operations_examples: (id) => new GetOperationsExamplesClientTool(id),
}
// Read-only static metadata for class-based tools (no instances)
@@ -120,6 +122,7 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
get_trigger_examples: (GetTriggerExamplesClientTool as any)?.metadata,
get_examples_rag: (GetExamplesRagClientTool as any)?.metadata,
oauth_request_access: (OAuthRequestAccessClientTool as any)?.metadata,
get_operations_examples: (GetOperationsExamplesClientTool as any)?.metadata,
}
function ensureClientToolInstance(toolName: string | undefined, toolCallId: string | undefined) {
@@ -1273,7 +1276,8 @@ async function* parseSSEStream(
const initialState = {
mode: 'agent' as const,
selectedModel: 'claude-4.5-sonnet' as CopilotStore['selectedModel'],
agentPrefetch: true,
agentPrefetch: false,
enabledModels: null as string[] | null, // Null means not loaded yet, empty array means all disabled
isCollapsed: false,
currentChat: null as CopilotChat | null,
chats: [] as CopilotChat[],
@@ -2181,6 +2185,7 @@ export const useCopilotStore = create<CopilotStore>()(
setSelectedModel: (model) => set({ selectedModel: model }),
setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }),
setEnabledModels: (models) => set({ enabledModels: models }),
}))
)

View File

@@ -80,6 +80,7 @@ export interface CopilotState {
| 'claude-4.5-sonnet'
| 'claude-4.1-opus'
agentPrefetch: boolean
enabledModels: string[] | null // Null means not loaded yet, array of model IDs when loaded
isCollapsed: boolean
currentChat: CopilotChat | null
@@ -129,6 +130,7 @@ export interface CopilotActions {
setMode: (mode: CopilotMode) => void
setSelectedModel: (model: CopilotStore['selectedModel']) => void
setAgentPrefetch: (prefetch: boolean) => void
setEnabledModels: (models: string[] | null) => void
setWorkflowId: (workflowId: string | null) => Promise<void>
validateCurrentChat: () => boolean

View File

@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlockOutputs } from '@/lib/workflows/block-outputs'
import { getBlock } from '@/blocks'
import { resolveOutputType } from '@/blocks/utils'
import {
@@ -166,7 +167,11 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
}
})
const outputs = resolveOutputType(blockConfig.outputs)
// Get outputs based on trigger mode
const triggerMode = blockProperties?.triggerMode ?? false
const outputs = triggerMode
? getBlockOutputs(type, subBlocks, triggerMode)
: resolveOutputType(blockConfig.outputs)
const newState = {
blocks: {
@@ -182,7 +187,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
horizontalHandles: blockProperties?.horizontalHandles ?? true,
isWide: blockProperties?.isWide ?? false,
advancedMode: blockProperties?.advancedMode ?? false,
triggerMode: blockProperties?.triggerMode ?? false,
triggerMode: triggerMode,
height: blockProperties?.height ?? 0,
layout: {},
data: nodeData,