mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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:
committed by
GitHub
parent
4da355d269
commit
c42d2a32f3
@@ -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',
|
||||
}),
|
||||
})
|
||||
|
||||
131
apps/sim/app/api/copilot/user-models/route.ts
Normal file
131
apps/sim/app/api/copilot/user-models/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -440,6 +440,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
onModeChange={setMode}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
panelWidth={panelWidth}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user