mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(copilot): copilot edit router block accepts semantic handles (#2857)
* Fix copilot diff controls * Fix router block for copilot * Fix queue * Fix lint * Get block options and config for subflows * Lint
This commit is contained in:
+9
-1
@@ -8,6 +8,7 @@ import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useCopilotStore, usePanelStore } from '@/stores/panel'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('DiffControls')
|
||||
const NOTIFICATION_WIDTH = 240
|
||||
@@ -37,8 +38,15 @@ export const DiffControls = memo(function DiffControls() {
|
||||
)
|
||||
)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry(
|
||||
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
|
||||
)
|
||||
|
||||
const allNotifications = useNotificationStore((state) => state.notifications)
|
||||
const hasVisibleNotifications = allNotifications.length > 0
|
||||
const hasVisibleNotifications = useMemo(() => {
|
||||
if (!activeWorkflowId) return false
|
||||
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
|
||||
}, [allNotifications, activeWorkflowId])
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
+2
-2
@@ -94,7 +94,6 @@ export function useCheckpointManagement(
|
||||
|
||||
setShowRestoreConfirmation(false)
|
||||
onRevertModeChange?.(false)
|
||||
onEditModeChange?.(true)
|
||||
|
||||
logger.info('Checkpoint reverted and removed from message', {
|
||||
messageId: message.id,
|
||||
@@ -115,7 +114,6 @@ export function useCheckpointManagement(
|
||||
messages,
|
||||
currentChat,
|
||||
onRevertModeChange,
|
||||
onEditModeChange,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -176,6 +174,7 @@ export function useCheckpointManagement(
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
}
|
||||
pendingEditRef.current = null
|
||||
@@ -219,6 +218,7 @@ export function useCheckpointManagement(
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
}
|
||||
pendingEditRef.current = null
|
||||
|
||||
+1
@@ -166,6 +166,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -356,6 +356,64 @@ export const getBlockConfigServerTool: BaseServerTool<
|
||||
const logger = createLogger('GetBlockConfigServerTool')
|
||||
logger.debug('Executing get_block_config', { blockType, operation, trigger })
|
||||
|
||||
if (blockType === 'loop') {
|
||||
const result = {
|
||||
blockType,
|
||||
blockName: 'Loop',
|
||||
operation,
|
||||
trigger,
|
||||
inputs: {
|
||||
loopType: {
|
||||
type: 'string',
|
||||
description: 'Loop type',
|
||||
options: ['for', 'forEach', 'while', 'doWhile'],
|
||||
default: 'for',
|
||||
},
|
||||
iterations: {
|
||||
type: 'number',
|
||||
description: 'Number of iterations (for loop type "for")',
|
||||
},
|
||||
collection: {
|
||||
type: 'string',
|
||||
description: 'Collection to iterate (for loop type "forEach")',
|
||||
},
|
||||
condition: {
|
||||
type: 'string',
|
||||
description: 'Loop condition (for loop types "while" and "doWhile")',
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
}
|
||||
return GetBlockConfigResult.parse(result)
|
||||
}
|
||||
|
||||
if (blockType === 'parallel') {
|
||||
const result = {
|
||||
blockType,
|
||||
blockName: 'Parallel',
|
||||
operation,
|
||||
trigger,
|
||||
inputs: {
|
||||
parallelType: {
|
||||
type: 'string',
|
||||
description: 'Parallel type',
|
||||
options: ['count', 'collection'],
|
||||
default: 'count',
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of parallel branches (for parallel type "count")',
|
||||
},
|
||||
collection: {
|
||||
type: 'string',
|
||||
description: 'Collection to branch over (for parallel type "collection")',
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
}
|
||||
return GetBlockConfigResult.parse(result)
|
||||
}
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
|
||||
@@ -21,6 +21,40 @@ export const getBlockOptionsServerTool: BaseServerTool<
|
||||
const logger = createLogger('GetBlockOptionsServerTool')
|
||||
logger.debug('Executing get_block_options', { blockId })
|
||||
|
||||
if (blockId === 'loop') {
|
||||
const result = {
|
||||
blockId,
|
||||
blockName: 'Loop',
|
||||
operations: [
|
||||
{ id: 'for', name: 'For', description: 'Run a fixed number of iterations.' },
|
||||
{ id: 'forEach', name: 'For each', description: 'Iterate over a collection.' },
|
||||
{ id: 'while', name: 'While', description: 'Repeat while a condition is true.' },
|
||||
{
|
||||
id: 'doWhile',
|
||||
name: 'Do while',
|
||||
description: 'Run once, then repeat while a condition is true.',
|
||||
},
|
||||
],
|
||||
}
|
||||
return GetBlockOptionsResult.parse(result)
|
||||
}
|
||||
|
||||
if (blockId === 'parallel') {
|
||||
const result = {
|
||||
blockId,
|
||||
blockName: 'Parallel',
|
||||
operations: [
|
||||
{ id: 'count', name: 'Count', description: 'Run a fixed number of parallel branches.' },
|
||||
{
|
||||
id: 'collection',
|
||||
name: 'Collection',
|
||||
description: 'Run one branch per collection item.',
|
||||
},
|
||||
],
|
||||
}
|
||||
return GetBlockOptionsResult.parse(result)
|
||||
}
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
|
||||
@@ -878,6 +878,25 @@ function validateSourceHandleForBlock(
|
||||
error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, ${EDGE.ROUTER_PREFIX}{targetId}, error`,
|
||||
}
|
||||
|
||||
case 'router_v2': {
|
||||
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
|
||||
}
|
||||
}
|
||||
|
||||
const routesValue = sourceBlock?.subBlocks?.routes?.value
|
||||
if (!routesValue) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid router handle "${sourceHandle}" - no routes defined`,
|
||||
}
|
||||
}
|
||||
|
||||
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
|
||||
}
|
||||
|
||||
default:
|
||||
if (sourceHandle === 'source') {
|
||||
return { valid: true }
|
||||
@@ -963,6 +982,85 @@ function validateConditionHandle(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates router handle references a valid route in the block.
|
||||
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1)
|
||||
*/
|
||||
function validateRouterHandle(
|
||||
sourceHandle: string,
|
||||
blockId: string,
|
||||
routesValue: string | any[]
|
||||
): EdgeHandleValidationResult {
|
||||
let routes: any[]
|
||||
if (typeof routesValue === 'string') {
|
||||
try {
|
||||
routes = JSON.parse(routesValue)
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot validate router handle "${sourceHandle}" - routes is not valid JSON`,
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(routesValue)) {
|
||||
routes = routesValue
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot validate router handle "${sourceHandle}" - routes is not an array`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(routes) || routes.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid router handle "${sourceHandle}" - no routes defined`,
|
||||
}
|
||||
}
|
||||
|
||||
const validHandles = new Set<string>()
|
||||
const semanticPrefix = `router-${blockId}-`
|
||||
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
const route = routes[i]
|
||||
|
||||
// Accept internal ID format: router-{uuid}
|
||||
if (route.id) {
|
||||
validHandles.add(`router-${route.id}`)
|
||||
}
|
||||
|
||||
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
|
||||
validHandles.add(`${semanticPrefix}route-${i + 1}`)
|
||||
|
||||
// Accept normalized title format: router-{blockId}-{normalized-title}
|
||||
// Normalize: lowercase, replace spaces with dashes, remove special chars
|
||||
if (route.title && typeof route.title === 'string') {
|
||||
const normalizedTitle = route.title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
if (normalizedTitle) {
|
||||
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validHandles.has(sourceHandle)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const validOptions = Array.from(validHandles).slice(0, 5)
|
||||
const moreCount = validHandles.size - validOptions.length
|
||||
let validOptionsStr = validOptions.join(', ')
|
||||
if (moreCount > 0) {
|
||||
validOptionsStr += `, ... and ${moreCount} more`
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates target handle is valid (must be 'target')
|
||||
*/
|
||||
|
||||
@@ -268,12 +268,128 @@ function sanitizeSubBlocks(
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if)
|
||||
*/
|
||||
function convertConditionHandleToSemantic(
|
||||
handle: string,
|
||||
blockId: string,
|
||||
block: BlockState
|
||||
): string {
|
||||
if (!handle.startsWith('condition-')) {
|
||||
return handle
|
||||
}
|
||||
|
||||
// Extract the condition UUID from the handle
|
||||
const conditionId = handle.substring('condition-'.length)
|
||||
|
||||
// Get conditions from block subBlocks
|
||||
const conditionsValue = block.subBlocks?.conditions?.value
|
||||
if (!conditionsValue || typeof conditionsValue !== 'string') {
|
||||
return handle
|
||||
}
|
||||
|
||||
let conditions: Array<{ id: string; title: string }>
|
||||
try {
|
||||
conditions = JSON.parse(conditionsValue)
|
||||
} catch {
|
||||
return handle
|
||||
}
|
||||
|
||||
if (!Array.isArray(conditions)) {
|
||||
return handle
|
||||
}
|
||||
|
||||
// Find the condition by ID and generate semantic handle
|
||||
let elseIfCount = 0
|
||||
for (const condition of conditions) {
|
||||
const title = condition.title?.toLowerCase()
|
||||
if (condition.id === conditionId) {
|
||||
if (title === 'if') {
|
||||
return `condition-${blockId}-if`
|
||||
}
|
||||
if (title === 'else if') {
|
||||
elseIfCount++
|
||||
return elseIfCount === 1
|
||||
? `condition-${blockId}-else-if`
|
||||
: `condition-${blockId}-else-if-${elseIfCount}`
|
||||
}
|
||||
if (title === 'else') {
|
||||
return `condition-${blockId}-else`
|
||||
}
|
||||
}
|
||||
// Count else-ifs as we iterate
|
||||
if (title === 'else if') {
|
||||
elseIfCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return original handle if condition not found
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N)
|
||||
*/
|
||||
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string {
|
||||
if (!handle.startsWith('router-')) {
|
||||
return handle
|
||||
}
|
||||
|
||||
// Extract the route UUID from the handle
|
||||
const routeId = handle.substring('router-'.length)
|
||||
|
||||
// Get routes from block subBlocks
|
||||
const routesValue = block.subBlocks?.routes?.value
|
||||
if (!routesValue || typeof routesValue !== 'string') {
|
||||
return handle
|
||||
}
|
||||
|
||||
let routes: Array<{ id: string; title?: string }>
|
||||
try {
|
||||
routes = JSON.parse(routesValue)
|
||||
} catch {
|
||||
return handle
|
||||
}
|
||||
|
||||
if (!Array.isArray(routes)) {
|
||||
return handle
|
||||
}
|
||||
|
||||
// Find the route by ID and generate semantic handle (1-indexed)
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
if (routes[i].id === routeId) {
|
||||
return `router-${blockId}-route-${i + 1}`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return original handle if route not found
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert source handle to semantic format for condition and router blocks
|
||||
*/
|
||||
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string {
|
||||
if (handle.startsWith('condition-') && block.type === 'condition') {
|
||||
return convertConditionHandleToSemantic(handle, blockId, block)
|
||||
}
|
||||
|
||||
if (handle.startsWith('router-') && block.type === 'router_v2') {
|
||||
return convertRouterHandleToSemantic(handle, blockId, block)
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract connections for a block from edges and format as operations-style connections
|
||||
* Converts internal UUID handles to semantic format for training data
|
||||
*/
|
||||
function extractConnectionsForBlock(
|
||||
blockId: string,
|
||||
edges: WorkflowState['edges']
|
||||
edges: WorkflowState['edges'],
|
||||
block: BlockState
|
||||
): Record<string, string | string[]> | undefined {
|
||||
const connections: Record<string, string[]> = {}
|
||||
|
||||
@@ -284,9 +400,12 @@ function extractConnectionsForBlock(
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Group by source handle
|
||||
// Group by source handle (converting to semantic format)
|
||||
for (const edge of outgoingEdges) {
|
||||
const handle = edge.sourceHandle || 'source'
|
||||
let handle = edge.sourceHandle || 'source'
|
||||
|
||||
// Convert internal UUID handles to semantic format
|
||||
handle = convertToSemanticHandle(handle, blockId, block)
|
||||
|
||||
if (!connections[handle]) {
|
||||
connections[handle] = []
|
||||
@@ -321,7 +440,7 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
|
||||
|
||||
// Helper to recursively sanitize a block and its children
|
||||
const sanitizeBlock = (blockId: string, block: BlockState): CopilotBlockState => {
|
||||
const connections = extractConnectionsForBlock(blockId, state.edges)
|
||||
const connections = extractConnectionsForBlock(blockId, state.edges, block)
|
||||
|
||||
// For loop/parallel blocks, extract config from block.data instead of subBlocks
|
||||
let inputs: Record<string, string | number | string[][] | object>
|
||||
|
||||
@@ -2562,16 +2562,18 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
fileAttachments,
|
||||
contexts,
|
||||
messageId,
|
||||
queueIfBusy = true,
|
||||
} = options as {
|
||||
stream?: boolean
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
messageId?: string
|
||||
queueIfBusy?: boolean
|
||||
}
|
||||
|
||||
if (!workflowId) return
|
||||
|
||||
// If already sending a message, queue this one instead
|
||||
// If already sending a message, queue this one instead unless bypassing queue
|
||||
if (isSendingMessage && !activeAbortController) {
|
||||
logger.warn('[Copilot] sendMessage: stale sending state detected, clearing', {
|
||||
originalMessageId: messageId,
|
||||
@@ -2583,12 +2585,15 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
})
|
||||
set({ isSendingMessage: false, abortController: null })
|
||||
} else if (isSendingMessage) {
|
||||
get().addToQueue(message, { fileAttachments, contexts, messageId })
|
||||
logger.info('[Copilot] Message queued (already sending)', {
|
||||
queueLength: get().messageQueue.length + 1,
|
||||
originalMessageId: messageId,
|
||||
})
|
||||
return
|
||||
if (queueIfBusy) {
|
||||
get().addToQueue(message, { fileAttachments, contexts, messageId })
|
||||
logger.info('[Copilot] Message queued (already sending)', {
|
||||
queueLength: get().messageQueue.length + 1,
|
||||
originalMessageId: messageId,
|
||||
})
|
||||
return
|
||||
}
|
||||
get().abortMessage({ suppressContinueOption: true })
|
||||
}
|
||||
|
||||
const nextAbortController = new AbortController()
|
||||
|
||||
@@ -179,6 +179,7 @@ export interface CopilotActions {
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
messageId?: string
|
||||
queueIfBusy?: boolean
|
||||
}
|
||||
) => Promise<void>
|
||||
abortMessage: (options?: { suppressContinueOption?: boolean }) => void
|
||||
|
||||
Reference in New Issue
Block a user