Compare commits

...

6 Commits

Author SHA1 Message Date
Siddharth Ganesan
fd5695e438 Fix lint 2026-01-16 15:35:50 -08:00
Siddharth Ganesan
8bc1092a68 Fix queue 2026-01-16 15:34:56 -08:00
Siddharth Ganesan
af06c4f606 Fix router block for copilot 2026-01-16 14:59:57 -08:00
Siddharth Ganesan
40a7825e36 Fix copilot diff controls 2026-01-16 14:42:23 -08:00
Waleed
ce3ddb6ba0 improvement(deployed-mcp): added the ability to make the visibility for deployed mcp tools public, updated UX (#2853)
* improvement(deployed-mcp): added the ability to make the visibility for deployed mcp tools public, updated UX

* use reactquery

* migrated chats to use reactquery, upgraded entire deploymodal to use reactquery instead of manual state management

* added hooks for chat chats and updated callers to all use reactquery

* fix

* updated comments

* consolidated utils
2026-01-16 14:18:39 -08:00
Siddharth Ganesan
8361931cdf fix(copilot): fix copilot bugs (#2855)
* Fix edit workflow returning bad state

* Fix block id edit, slash commands at end, thinking tag resolution, add continue button

* Clean up autosend and continue options and enable mention menu

* Cleanup

* Fix thinking tags

* Fix thinking text

* Fix get block options text

* Fix bugs

* Fix redeploy

* Fix loading indicators

* User input expansion

* Normalize copilot subblock ids

* Fix handlecancelcheckpoint
2026-01-16 13:57:55 -08:00
68 changed files with 13935 additions and 1800 deletions

View File

@@ -11,10 +11,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"content/docs/execution/index.mdx",
"content/docs/connections/index.mdx",
".next/dev/types/**/*.ts"
"content/docs/connections/index.mdx"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next"]
}

View File

@@ -8,6 +8,7 @@ import { getSession } from '@/lib/auth'
import { generateChatTitle } from '@/lib/copilot/chat-title'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -40,34 +41,8 @@ const ChatMessageSchema = z.object({
userMessageId: z.string().optional(), // ID from frontend for the user message
chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
model: z
.enum([
'gpt-5-fast',
'gpt-5',
'gpt-5-medium',
'gpt-5-high',
'gpt-5.1-fast',
'gpt-5.1',
'gpt-5.1-medium',
'gpt-5.1-high',
'gpt-5-codex',
'gpt-5.1-codex',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.2-pro',
'gpt-4o',
'gpt-4.1',
'o3',
'claude-4-sonnet',
'claude-4.5-haiku',
'claude-4.5-sonnet',
'claude-4.5-opus',
'claude-4.1-opus',
'gemini-3-pro',
])
.optional()
.default('claude-4.5-opus'),
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'),
model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.5-opus'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
@@ -295,7 +270,8 @@ export async function POST(req: NextRequest) {
}
const defaults = getCopilotModel('chat')
const modelToUse = env.COPILOT_MODEL || defaults.model
const selectedModel = model || defaults.model
const envModel = env.COPILOT_MODEL || defaults.model
let providerConfig: CopilotProviderConfig | undefined
const providerEnv = env.COPILOT_PROVIDER as any
@@ -304,7 +280,7 @@ export async function POST(req: NextRequest) {
if (providerEnv === 'azure-openai') {
providerConfig = {
provider: 'azure-openai',
model: modelToUse,
model: envModel,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: 'preview',
endpoint: env.AZURE_OPENAI_ENDPOINT,
@@ -312,7 +288,7 @@ export async function POST(req: NextRequest) {
} else if (providerEnv === 'vertex') {
providerConfig = {
provider: 'vertex',
model: modelToUse,
model: envModel,
apiKey: env.COPILOT_API_KEY,
vertexProject: env.VERTEX_PROJECT,
vertexLocation: env.VERTEX_LOCATION,
@@ -320,12 +296,15 @@ export async function POST(req: NextRequest) {
} else {
providerConfig = {
provider: providerEnv,
model: modelToUse,
model: selectedModel,
apiKey: env.COPILOT_API_KEY,
}
}
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
// Determine conversationId to use for this request
const effectiveConversationId =
(currentChat?.conversationId as string | undefined) || conversationId
@@ -345,7 +324,7 @@ export async function POST(req: NextRequest) {
}
} | null = null
if (mode === 'agent') {
if (effectiveMode === 'build') {
// Build base tools (executed locally, not deferred)
// Include function_execute for code execution capability
baseTools = [
@@ -452,8 +431,8 @@ export async function POST(req: NextRequest) {
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
model: model,
mode: mode,
model: selectedModel,
mode: transportMode,
messageId: userMessageIdToUse,
version: SIM_AGENT_VERSION,
...(providerConfig ? { provider: providerConfig } : {}),
@@ -477,7 +456,7 @@ export async function POST(req: NextRequest) {
hasConversationId: !!effectiveConversationId,
hasFileAttachments: processedFileContents.length > 0,
messageLength: message.length,
mode,
mode: effectiveMode,
hasTools: integrationTools.length > 0,
toolCount: integrationTools.length,
hasBaseTools: baseTools.length > 0,

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { COPILOT_MODES } from '@/lib/copilot/models'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
@@ -45,7 +46,7 @@ const UpdateMessagesSchema = z.object({
planArtifact: z.string().nullable().optional(),
config: z
.object({
mode: z.enum(['ask', 'build', 'plan']).optional(),
mode: z.enum(COPILOT_MODES).optional(),
model: z.string().optional(),
})
.nullable()

View File

@@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import type { CopilotModelId } from '@/lib/copilot/models'
import { db } from '@/../../packages/db'
import { settings } from '@/../../packages/db/schema'
const logger = createLogger('CopilotUserModelsAPI')
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
const DEFAULT_ENABLED_MODELS: Record<CopilotModelId, boolean> = {
'gpt-4o': false,
'gpt-4.1': false,
'gpt-5-fast': false,
@@ -28,7 +29,7 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
'claude-4.5-haiku': true,
'claude-4.5-sonnet': true,
'claude-4.5-opus': true,
// 'claude-4.1-opus': true,
'claude-4.1-opus': false,
'gemini-3-pro': true,
}
@@ -54,7 +55,9 @@ export async function GET(request: NextRequest) {
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
mergedModels[modelId] = enabled
if (modelId in mergedModels) {
mergedModels[modelId as CopilotModelId] = enabled
}
}
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(

View File

@@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkflowMcpServeAPI')
@@ -52,6 +53,8 @@ async function getServer(serverId: string) {
id: workflowMcpServer.id,
name: workflowMcpServer.name,
workspaceId: workflowMcpServer.workspaceId,
isPublic: workflowMcpServer.isPublic,
createdBy: workflowMcpServer.createdBy,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
@@ -90,9 +93,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!server.isPublic) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
const body = await request.json()
@@ -138,7 +143,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
id,
serverId,
rpcParams as { name: string; arguments?: Record<string, unknown> },
apiKey
apiKey,
server.isPublic ? server.createdBy : undefined
)
default:
@@ -200,7 +206,8 @@ async function handleToolsCall(
id: RequestId,
serverId: string,
params: { name: string; arguments?: Record<string, unknown> } | undefined,
apiKey?: string | null
apiKey?: string | null,
publicServerOwnerId?: string
): Promise<NextResponse> {
try {
if (!params?.name) {
@@ -243,7 +250,13 @@ async function handleToolsCall(
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['X-API-Key'] = apiKey
if (publicServerOwnerId) {
const internalToken = await generateInternalToken(publicServerOwnerId)
headers.Authorization = `Bearer ${internalToken}`
} else if (apiKey) {
headers['X-API-Key'] = apiKey
}
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)

View File

@@ -31,6 +31,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
})
@@ -98,6 +99,9 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
if (body.description !== undefined) {
updateData.description = body.description?.trim() || null
}
if (body.isPublic !== undefined) {
updateData.isPublic = body.isPublic
}
const [updatedServer] = await db
.update(workflowMcpServer)

View File

@@ -26,7 +26,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -72,7 +71,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -139,7 +137,6 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)

View File

@@ -6,24 +6,10 @@ import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
const logger = createLogger('WorkflowMcpToolsAPI')
/**
* Check if a workflow has a valid start block by loading from database
*/
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
return hasValidStartBlockInState(normalizedData)
} catch (error) {
logger.warn('Error checking for start block:', error)
return false
}
}
export const dynamic = 'force-dynamic'
interface RouteParams {
@@ -40,7 +26,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -53,7 +38,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Get tools with workflow details
const tools = await db
.select({
id: workflowMcpTool.id,
@@ -107,7 +91,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -120,7 +103,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Verify workflow exists and is deployed
const [workflowRecord] = await db
.select({
id: workflow.id,
@@ -137,7 +119,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
}
// Verify workflow belongs to the same workspace
if (workflowRecord.workspaceId !== workspaceId) {
return createMcpErrorResponse(
new Error('Workflow does not belong to this workspace'),
@@ -154,7 +135,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify workflow has a valid start block
const hasStartBlock = await hasValidStartBlock(body.workflowId)
if (!hasStartBlock) {
return createMcpErrorResponse(
@@ -164,7 +144,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Check if tool already exists for this workflow
const [existingTool] = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
@@ -190,7 +169,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
workflowRecord.description ||
`Execute ${workflowRecord.name} workflow`
// Create the tool
const toolId = crypto.randomUUID()
const [tool] = await db
.insert(workflowMcpTool)

View File

@@ -1,10 +1,12 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
const logger = createLogger('WorkflowMcpServersAPI')
@@ -25,18 +27,18 @@ export const GET = withMcpAuth('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
toolCount: sql<number>`(
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
)`.as('tool_count'),
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.workspaceId, workspaceId))
// Fetch all tools for these servers
const serverIds = servers.map((s) => s.id)
const tools =
serverIds.length > 0
@@ -49,7 +51,6 @@ export const GET = withMcpAuth('read')(
.where(inArray(workflowMcpTool.serverId, serverIds))
: []
// Group tool names by server
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
@@ -58,7 +59,6 @@ export const GET = withMcpAuth('read')(
toolNamesByServer[tool.serverId].push(tool.toolName)
}
// Attach tool names to servers
const serversWithToolNames = servers.map((server) => ({
...server,
toolNames: toolNamesByServer[server.id] || [],
@@ -90,6 +90,7 @@ export const POST = withMcpAuth('write')(
logger.info(`[${requestId}] Creating workflow MCP server:`, {
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
})
if (!body.name) {
@@ -110,16 +111,76 @@ export const POST = withMcpAuth('write')(
createdBy: userId,
name: body.name.trim(),
description: body.description?.trim() || null,
isPublic: body.isPublic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
const workflowIds: string[] = body.workflowIds || []
const addedTools: Array<{ workflowId: string; toolName: string }> = []
if (workflowIds.length > 0) {
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(inArray(workflow.id, workflowIds))
for (const workflowRecord of workflows) {
if (workflowRecord.workspaceId !== workspaceId) {
logger.warn(
`[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace`
)
continue
}
if (!workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`)
continue
}
const hasStartBlock = await hasValidStartBlock(workflowRecord.id)
if (!hasStartBlock) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`)
continue
}
const toolName = sanitizeToolName(workflowRecord.name)
const toolDescription =
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
const toolId = crypto.randomUUID()
await db.insert(workflowMcpTool).values({
id: toolId,
serverId,
workflowId: workflowRecord.id,
toolName,
toolDescription,
parameterSchema: {},
createdAt: new Date(),
updatedAt: new Date(),
})
addedTools.push({ workflowId: workflowRecord.id, toolName })
}
logger.info(
`[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`,
addedTools.map((t) => t.toolName)
)
}
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
)
return createMcpSuccessResponse({ server }, 201)
return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
return createMcpErrorResponse(

View File

@@ -22,6 +22,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
.select({
id: chat.id,
identifier: chat.identifier,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
authType: chat.authType,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
password: chat.password,
isActive: chat.isActive,
})
.from(chat)
@@ -34,6 +41,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
? {
id: deploymentResults[0].id,
identifier: deploymentResults[0].identifier,
title: deploymentResults[0].title,
description: deploymentResults[0].description,
customizations: deploymentResults[0].customizations,
authType: deploymentResults[0].authType,
allowedEmails: deploymentResults[0].allowedEmails,
outputConfigs: deploymentResults[0].outputConfigs,
hasPassword: Boolean(deploymentResults[0].password),
}
: null

View File

@@ -48,7 +48,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
const logger = createLogger('Document')
@@ -313,69 +313,22 @@ export function Document({
isFetching: isFetchingChunks,
} = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL)
const [searchResults, setSearchResults] = useState<ChunkData[]>([])
const [isLoadingSearch, setIsLoadingSearch] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
useEffect(() => {
if (!debouncedSearchQuery.trim()) {
setSearchResults([])
setSearchError(null)
return
const {
data: searchResults = [],
isLoading: isLoadingSearch,
error: searchQueryError,
} = useDocumentChunkSearchQuery(
{
knowledgeBaseId,
documentId,
search: debouncedSearchQuery,
},
{
enabled: Boolean(debouncedSearchQuery.trim()),
}
)
let isMounted = true
const searchAllChunks = async () => {
try {
setIsLoadingSearch(true)
setSearchError(null)
const allResults: ChunkData[] = []
let hasMore = true
let offset = 0
const limit = 100
while (hasMore && isMounted) {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks?search=${encodeURIComponent(debouncedSearchQuery)}&limit=${limit}&offset=${offset}`
)
if (!response.ok) {
throw new Error('Search failed')
}
const result = await response.json()
if (result.success && result.data) {
allResults.push(...result.data)
hasMore = result.pagination?.hasMore || false
offset += limit
} else {
hasMore = false
}
}
if (isMounted) {
setSearchResults(allResults)
}
} catch (err) {
if (isMounted) {
setSearchError(err instanceof Error ? err.message : 'Search failed')
}
} finally {
if (isMounted) {
setIsLoadingSearch(false)
}
}
}
searchAllChunks()
return () => {
isMounted = false
}
}, [debouncedSearchQuery, knowledgeBaseId, documentId])
const searchError = searchQueryError instanceof Error ? searchQueryError.message : null
const [selectedChunks, setSelectedChunks] = useState<Set<string>>(new Set())
const [selectedChunk, setSelectedChunk] = useState<ChunkData | null>(null)

View File

@@ -1,9 +1,10 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { X } from 'lucide-react'
import { Badge, Combobox, type ComboboxOption } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useWorkflows } from '@/hooks/queries/workflows'
interface WorkflowSelectorProps {
workspaceId: string
@@ -25,26 +26,9 @@ export function WorkflowSelector({
onChange,
error,
}: WorkflowSelectorProps) {
const [workflows, setWorkflows] = useState<Array<{ id: string; name: string }>>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
setIsLoading(true)
const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (response.ok) {
const data = await response.json()
setWorkflows(data.data || [])
}
} catch {
setWorkflows([])
} finally {
setIsLoading(false)
}
}
load()
}, [workspaceId])
const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, {
syncRegistry: false,
})
const options: ComboboxOption[] = useMemo(() => {
return workflows.map((w) => ({

View File

@@ -9,8 +9,6 @@ import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
const NOTIFICATION_WIDTH = 240
@@ -19,26 +17,22 @@ const NOTIFICATION_GAP = 16
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isPanelResizing = usePanelStore((state) => state.isResizing)
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
baselineWorkflow: state.baselineWorkflow,
}),
[]
)
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
}),
[]
)
)
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
const { updatePreviewToolCallState } = useCopilotStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
currentChat: state.currentChat,
messages: state.messages,
}),
[]
)
@@ -54,154 +48,6 @@ export const DiffControls = memo(function DiffControls() {
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
}, [allNotifications, activeWorkflowId])
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
workflowId: activeWorkflowId,
chatId: currentChat?.id,
})
return false
}
try {
logger.info('Creating checkpoint before accepting changes')
// Use the baseline workflow (state before diff) instead of current state
// This ensures reverting to the checkpoint restores the pre-diff state
const rawState = baselineWorkflow || useWorkflowStore.getState().getWorkflowState()
// The baseline already has merged subblock values, but we'll merge again to be safe
// This ensures all user inputs and subblock data are captured
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, activeWorkflowId)
// Filter and complete blocks to ensure all required fields are present
// This matches the validation logic from /api/workflows/[id]/state
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
(acc, [blockId, block]) => {
if (block.type && block.name) {
// Ensure all required fields are present
acc[blockId] = {
...block,
id: block.id || blockId, // Ensure id field is set
enabled: block.enabled !== undefined ? block.enabled : true,
horizontalHandles:
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
height: block.height !== undefined ? block.height : 90,
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
data: block.data || {},
position: block.position || { x: 0, y: 0 }, // Ensure position exists
}
}
return acc
},
{} as typeof rawState.blocks
)
// Clean the workflow state - only include valid fields, exclude null/undefined values
const workflowState = {
blocks: filteredBlocks,
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
lastSaved: rawState.lastSaved || Date.now(),
deploymentStatuses: rawState.deploymentStatuses || {},
}
logger.info('Prepared complete workflow state for checkpoint', {
blocksCount: Object.keys(workflowState.blocks).length,
edgesCount: workflowState.edges.length,
loopsCount: Object.keys(workflowState.loops).length,
parallelsCount: Object.keys(workflowState.parallels).length,
hasRequiredFields: Object.values(workflowState.blocks).every(
(block) => block.id && block.type && block.name && block.position
),
hasSubblockValues: Object.values(workflowState.blocks).some((block) =>
Object.values(block.subBlocks || {}).some(
(subblock) => subblock.value !== null && subblock.value !== undefined
)
),
sampleBlock: Object.values(workflowState.blocks)[0],
})
// Find the most recent user message ID from the current chat
const userMessages = messages.filter((msg) => msg.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
const messageId = lastUserMessage?.id
logger.info('Creating checkpoint with message association', {
totalMessages: messages.length,
userMessageCount: userMessages.length,
lastUserMessageId: messageId,
chatId: currentChat.id,
entireMessageArray: messages,
allMessageIds: messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content.substring(0, 50),
})),
selectedUserMessages: userMessages.map((m) => ({
id: m.id,
content: m.content.substring(0, 100),
})),
allRawMessageIds: messages.map((m) => m.id),
userMessageIds: userMessages.map((m) => m.id),
checkpointData: {
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId: messageId,
messageFound: !!lastUserMessage,
},
})
const response = await fetch('/api/copilot/checkpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId,
workflowState: JSON.stringify(workflowState),
}),
})
if (!response.ok) {
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
}
const result = await response.json()
const newCheckpoint = result.checkpoint
logger.info('Checkpoint created successfully', {
messageId,
chatId: currentChat.id,
checkpointId: newCheckpoint?.id,
})
// Update the copilot store immediately to show the checkpoint icon
if (newCheckpoint && messageId) {
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const existingCheckpoints = currentCheckpoints[messageId] || []
const updatedCheckpoints = {
...currentCheckpoints,
[messageId]: [newCheckpoint, ...existingCheckpoints],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
logger.info('Updated copilot store with new checkpoint', {
messageId,
checkpointId: newCheckpoint.id,
})
}
return true
} catch (error) {
logger.error('Failed to create checkpoint:', error)
return false
}
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(() => {
logger.info('Accepting proposed changes with backup protection')
@@ -238,12 +84,8 @@ export const DiffControls = memo(function DiffControls() {
})
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
createCheckpoint().catch((error) => {
logger.warn('Failed to create checkpoint after accept:', error)
})
logger.info('Accept triggered; UI will update optimistically')
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
}, [updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
logger.info('Rejecting proposed changes (optimistic)')

View File

@@ -1,4 +1,5 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/**
@@ -6,14 +7,23 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
*/
const CHARACTER_DELAY = 3
/**
* Props for the StreamingIndicator component
*/
interface StreamingIndicatorProps {
/** Optional class name for layout adjustments */
className?: string
}
/**
* StreamingIndicator shows animated dots during message streaming
* Used as a standalone indicator when no content has arrived yet
*
* @param props - Component props
* @returns Animated loading indicator
*/
export const StreamingIndicator = memo(() => (
<div className='flex h-[1.25rem] items-center text-muted-foreground'>
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
<div className='flex space-x-0.5'>
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />

View File

@@ -1,10 +1,20 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import CopilotMarkdownRenderer from './markdown-renderer'
/**
* Removes thinking tags (raw or escaped) from streamed content.
*/
function stripThinkingTags(text: string): string {
return text
.replace(/<\/?thinking[^>]*>/gi, '')
.replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
.trim()
}
/**
* Max height for thinking content before internal scrolling kicks in
*/
@@ -187,6 +197,9 @@ export function ThinkingBlock({
label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) {
// Strip thinking tags from content on render to handle persisted messages
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(0)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
@@ -209,10 +222,10 @@ export function ThinkingBlock({
return
}
if (!userCollapsedRef.current && content && content.trim().length > 0) {
if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
// Reset start time when streaming begins
useEffect(() => {
@@ -298,7 +311,7 @@ export function ThinkingBlock({
return `${seconds}s`
}
const hasContent = content && content.trim().length > 0
const hasContent = cleanContent.length > 0
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
const durationText = `${label} for ${formatDuration(duration)}`
@@ -374,7 +387,10 @@ export function ThinkingBlock({
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} />
<SmoothThinkingText
content={cleanContent}
isStreaming={isStreaming && !hasFollowingContent}
/>
</div>
</div>
)
@@ -412,7 +428,7 @@ export function ThinkingBlock({
>
{/* Completed thinking text - dimmed with markdown */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={content} />
<CopilotMarkdownRenderer content={cleanContent} />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
@@ -93,6 +93,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// UI state
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
const cancelEditRef = useRef<(() => void) | null>(null)
// Checkpoint management hook
const {
showRestoreConfirmation,
@@ -112,7 +114,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
messages,
messageCheckpoints,
onRevertModeChange,
onEditModeChange
onEditModeChange,
() => cancelEditRef.current?.()
)
// Message editing hook
@@ -142,6 +145,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
pendingEditRef,
})
cancelEditRef.current = handleCancelEdit
// Get clean text content with double newline parsing
const cleanTextContent = useMemo(() => {
if (!message.content) return ''
@@ -488,8 +493,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{/* Streaming indicator always at bottom during streaming */}
{isStreaming && <StreamingIndicator />}
{isStreaming && (
<StreamingIndicator className={!hasVisibleContent ? 'mt-1' : undefined} />
)}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>

View File

@@ -22,7 +22,8 @@ export function useCheckpointManagement(
messages: CopilotMessage[],
messageCheckpoints: any[],
onRevertModeChange?: (isReverting: boolean) => void,
onEditModeChange?: (isEditing: boolean) => void
onEditModeChange?: (isEditing: boolean) => void,
onCancelEdit?: () => void
) {
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
@@ -57,7 +58,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: messageCheckpoints.slice(1),
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -93,7 +94,6 @@ export function useCheckpointManagement(
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
onEditModeChange?.(true)
logger.info('Checkpoint reverted and removed from message', {
messageId: message.id,
@@ -114,7 +114,6 @@ export function useCheckpointManagement(
messages,
currentChat,
onRevertModeChange,
onEditModeChange,
])
/**
@@ -140,7 +139,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: messageCheckpoints.slice(1),
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -154,6 +153,8 @@ export function useCheckpointManagement(
}
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
const { sendMessage } = useCopilotStore.getState()
if (pendingEditRef.current) {
@@ -173,6 +174,7 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
@@ -180,15 +182,17 @@ export function useCheckpointManagement(
} finally {
setIsProcessingDiscard(false)
}
}, [messageCheckpoints, revertToCheckpoint, message, messages])
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
/**
* Cancels checkpoint discard and clears pending edit
*/
const handleCancelCheckpointDiscard = useCallback(() => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
pendingEditRef.current = null
}, [])
}, [onEditModeChange, onCancelEdit])
/**
* Continues with edit WITHOUT reverting checkpoint
@@ -214,11 +218,12 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
}
}, [message, messages])
}, [message, messages, onEditModeChange, onCancelEdit])
/**
* Handles keyboard events for restore confirmation (Escape/Enter)

View File

@@ -166,6 +166,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
},

View File

@@ -1446,8 +1446,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
blockType = blockType || op.block_type || ''
}
// Fallback name to type or ID
if (!blockName) blockName = blockType || blockId
if (!blockName) blockName = blockType || ''
if (!blockName && !blockType) {
continue
}
const change: BlockChange = { blockId, blockName, blockType }

View File

@@ -22,6 +22,9 @@ interface UseContextManagementProps {
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
const initializedRef = useRef(false)
const escapeRegex = useCallback((value: string) => {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}, [])
// Initialize with initial contexts when they're first provided (for edit mode)
useEffect(() => {
@@ -78,10 +81,10 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
// Check for slash command tokens or mention tokens based on kind
const isSlashCommand = c.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
const tokenWithSpaces = ` ${prefix}${c.label} `
const tokenAtStart = `${prefix}${c.label} `
// Token can appear with leading space OR at the start of the message
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart)
const tokenPattern = new RegExp(
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`
)
return tokenPattern.test(message)
})
return filtered.length === prev.length ? prev : filtered
})

View File

@@ -76,6 +76,15 @@ export function useMentionTokens({
ranges.push({ start: idx, end: idx + token.length, label })
fromIndex = idx + token.length
}
// Token at end of message without trailing space: "@label" or " /label"
const tokenAtEnd = `${prefix}${label}`
if (message.endsWith(tokenAtEnd)) {
const idx = message.lastIndexOf(tokenAtEnd)
const hasLeadingSpace = idx > 0 && message[idx - 1] === ' '
const start = hasLeadingSpace ? idx - 1 : idx
ranges.push({ start, end: message.length, label })
}
}
ranges.sort((a, b) => a.start - b.start)

View File

@@ -613,7 +613,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const insertTriggerAndOpenMenu = useCallback(
(trigger: '@' | '/') => {
if (disabled || isLoading) return
if (disabled) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
@@ -642,7 +642,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, isLoading, mentionMenu, message, setMessage]
[disabled, mentionMenu, message, setMessage]
)
const handleOpenMentionMenuWithAt = useCallback(
@@ -737,7 +737,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Insert @'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
disabled && 'cursor-not-allowed'
)}
>
<AtSign className='h-3 w-3' strokeWidth={1.75} />
@@ -749,7 +749,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Insert /'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
disabled && 'cursor-not-allowed'
)}
>
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
@@ -816,7 +816,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
placeholder={fileAttachments.isDragging ? 'Drop files here...' : effectivePlaceholder}
disabled={disabled}
rows={2}
className='relative z-[2] m-0 box-border h-auto min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
className='relative z-[2] m-0 box-border h-auto max-h-[120px] min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
/>
{/* Mention Menu Portal */}

View File

@@ -83,8 +83,7 @@ interface A2aDeployProps {
workflowNeedsRedeployment?: boolean
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
onAgentExistsChange?: (exists: boolean) => void
onPublishedChange?: (published: boolean) => void
/** Callback for when republish status changes - depends on local form state */
onNeedsRepublishChange?: (needsRepublish: boolean) => void
onDeployWorkflow?: () => Promise<void>
}
@@ -99,8 +98,6 @@ export function A2aDeploy({
workflowNeedsRedeployment,
onSubmittingChange,
onCanSaveChange,
onAgentExistsChange,
onPublishedChange,
onNeedsRepublishChange,
onDeployWorkflow,
}: A2aDeployProps) {
@@ -236,14 +233,6 @@ export function A2aDeploy({
}
}, [existingAgent, workflowName, workflowDescription])
useEffect(() => {
onAgentExistsChange?.(!!existingAgent)
}, [existingAgent, onAgentExistsChange])
useEffect(() => {
onPublishedChange?.(existingAgent?.isPublished ?? false)
}, [existingAgent?.isPublished, onPublishedChange])
const hasFormChanges = useMemo(() => {
if (!existingAgent) return false
const savedSchemes = existingAgent.authentication?.schemes || []

View File

@@ -29,9 +29,11 @@ import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import {
type AuthType,
type ChatFormData,
useChatDeployment,
useIdentifierValidation,
} from './hooks'
useCreateChat,
useDeleteChat,
useUpdateChat,
} from '@/hooks/queries/chats'
import { useIdentifierValidation } from './hooks'
const logger = createLogger('ChatDeploy')
@@ -45,7 +47,6 @@ interface ChatDeployProps {
existingChat: ExistingChat | null
isLoadingChat: boolean
onRefetchChat: () => Promise<void>
onChatExistsChange?: (exists: boolean) => void
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
@@ -97,7 +98,6 @@ export function ChatDeploy({
existingChat,
isLoadingChat,
onRefetchChat,
onChatExistsChange,
chatSubmitting,
setChatSubmitting,
onValidationChange,
@@ -121,8 +121,11 @@ export function ChatDeploy({
const [formData, setFormData] = useState<ChatFormData>(initialFormData)
const [errors, setErrors] = useState<FormErrors>({})
const { deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const createChatMutation = useCreateChat()
const updateChatMutation = useUpdateChat()
const deleteChatMutation = useDeleteChat()
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const [hasInitializedForm, setHasInitializedForm] = useState(false)
@@ -231,15 +234,26 @@ export function ChatDeploy({
return
}
const chatUrl = await deployChat(
workflowId,
formData,
deploymentInfo,
existingChat?.id,
imageUrl
)
let chatUrl: string
if (existingChat?.id) {
const result = await updateChatMutation.mutateAsync({
chatId: existingChat.id,
workflowId,
formData,
imageUrl,
})
chatUrl = result.chatUrl
} else {
const result = await createChatMutation.mutateAsync({
workflowId,
formData,
apiKey: deploymentInfo?.apiKey,
imageUrl,
})
chatUrl = result.chatUrl
}
onChatExistsChange?.(true)
onDeployed?.()
onVersionActivated?.()
@@ -266,18 +280,13 @@ export function ChatDeploy({
try {
setIsDeleting(true)
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
method: 'DELETE',
await deleteChatMutation.mutateAsync({
chatId: existingChat.id,
workflowId,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete chat')
}
setImageUrl(null)
setHasInitializedForm(false)
onChatExistsChange?.(false)
await onRefetchChat()
onDeploymentComplete?.()

View File

@@ -1,2 +1 @@
export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
export { useIdentifierValidation } from './use-identifier-validation'

View File

@@ -1,131 +0,0 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { OutputConfig } from '@/stores/chat/types'
const logger = createLogger('ChatDeployment')
export type AuthType = 'public' | 'password' | 'email' | 'sso'
export interface ChatFormData {
identifier: string
title: string
description: string
authType: AuthType
password: string
emails: string[]
welcomeMessage: string
selectedOutputBlocks: string[]
}
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputConfigs: z
.array(
z.object({
blockId: z.string(),
path: z.string(),
})
)
.optional()
.default([]),
})
/**
* Parses output block selections into structured output configs
*/
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
return selectedOutputBlocks
.map((outputId) => {
const firstUnderscoreIndex = outputId.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
if (blockId && path) {
return { blockId, path }
}
}
return null
})
.filter((config): config is OutputConfig => config !== null)
}
/**
* Hook for deploying or updating a chat interface
*/
export function useChatDeployment() {
const deployChat = useCallback(
async (
workflowId: string,
formData: ChatFormData,
deploymentInfo: { apiKey: string } | null,
existingChatId?: string,
imageUrl?: string | null
): Promise<string> => {
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
const payload = {
workflowId,
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey: deploymentInfo?.apiKey,
deployApiEnabled: !existingChatId,
}
chatSchema.parse(payload)
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
const method = existingChatId ? 'PATCH' : 'POST'
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
}
if (!result.chatUrl) {
throw new Error('Response missing chatUrl')
}
logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
return result.chatUrl
},
[]
)
return { deployChat }
}

View File

@@ -17,11 +17,17 @@ import { Skeleton } from '@/components/ui'
import { isDev } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import {
type FieldConfig,
useCreateForm,
useDeleteForm,
useFormByWorkflow,
useUpdateForm,
} from '@/hooks/queries/forms'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { EmbedCodeGenerator } from './components/embed-code-generator'
import { FormBuilder } from './components/form-builder'
import { useFormDeployment } from './hooks/use-form-deployment'
import { useIdentifierValidation } from './hooks/use-identifier-validation'
const logger = createLogger('FormDeploy')
@@ -34,38 +40,11 @@ interface FormErrors {
general?: string
}
interface FieldConfig {
name: string
type: string
label: string
description?: string
required?: boolean
}
export interface ExistingForm {
id: string
identifier: string
title: string
description?: string
customizations: {
primaryColor?: string
thankYouMessage?: string
logoUrl?: string
fieldConfigs?: FieldConfig[]
}
authType: 'public' | 'password' | 'email'
hasPassword?: boolean
allowedEmails?: string[]
showBranding: boolean
isActive: boolean
}
interface FormDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
onExistingFormChange?: (exists: boolean) => void
formSubmitting?: boolean
setFormSubmitting?: (submitting: boolean) => void
onDeployed?: () => Promise<void>
@@ -81,7 +60,6 @@ export function FormDeploy({
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
onExistingFormChange,
formSubmitting,
setFormSubmitting,
onDeployed,
@@ -95,8 +73,6 @@ export function FormDeploy({
const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public')
const [password, setPassword] = useState('')
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [existingForm, setExistingForm] = useState<ExistingForm | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [formUrl, setFormUrl] = useState('')
const [inputFields, setInputFields] = useState<{ name: string; type: string }[]>([])
const [showPasswordField, setShowPasswordField] = useState(false)
@@ -104,7 +80,12 @@ export function FormDeploy({
const [errors, setErrors] = useState<FormErrors>({})
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const { createForm, updateForm, deleteForm, isSubmitting } = useFormDeployment()
const { data: existingForm, isLoading } = useFormByWorkflow(workflowId)
const createFormMutation = useCreateForm()
const updateFormMutation = useUpdateForm()
const deleteFormMutation = useDeleteForm()
const isSubmitting = createFormMutation.isPending || updateFormMutation.isPending
const {
isChecking: isCheckingIdentifier,
@@ -124,75 +105,45 @@ export function FormDeploy({
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
// Fetch existing form deployment
// Populate form fields when existing form data is loaded
useEffect(() => {
async function fetchExistingForm() {
if (!workflowId) return
try {
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/form/status`)
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.form) {
const detailResponse = await fetch(`/api/form/manage/${data.form.id}`)
if (detailResponse.ok) {
const formDetail = await detailResponse.json()
const form = formDetail.form as ExistingForm
setExistingForm(form)
onExistingFormChange?.(true)
setIdentifier(form.identifier)
setTitle(form.title)
setDescription(form.description || '')
setThankYouMessage(
form.customizations?.thankYouMessage ||
'Your response has been submitted successfully.'
)
setAuthType(form.authType)
setEmailItems(
(form.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (form.customizations?.fieldConfigs) {
setFieldConfigs(form.customizations.fieldConfigs)
}
const baseUrl = getBaseUrl()
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) host = host.substring(4)
setFormUrl(`${url.protocol}//${host}/form/${form.identifier}`)
} catch {
setFormUrl(
isDev
? `http://localhost:3000/form/${form.identifier}`
: `https://sim.ai/form/${form.identifier}`
)
}
}
} else {
setExistingForm(null)
onExistingFormChange?.(false)
const workflowName =
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
?.name || 'Form'
setTitle(`${workflowName} Form`)
}
}
} catch (err) {
logger.error('Error fetching form deployment:', err)
} finally {
setIsLoading(false)
if (existingForm) {
setIdentifier(existingForm.identifier)
setTitle(existingForm.title)
setDescription(existingForm.description || '')
setThankYouMessage(
existingForm.customizations?.thankYouMessage ||
'Your response has been submitted successfully.'
)
setAuthType(existingForm.authType)
setEmailItems(
(existingForm.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (existingForm.customizations?.fieldConfigs) {
setFieldConfigs(existingForm.customizations.fieldConfigs)
}
const baseUrl = getBaseUrl()
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) host = host.substring(4)
setFormUrl(`${url.protocol}//${host}/form/${existingForm.identifier}`)
} catch {
setFormUrl(
isDev
? `http://localhost:3000/form/${existingForm.identifier}`
: `https://sim.ai/form/${existingForm.identifier}`
)
}
} else if (!isLoading) {
const workflowName =
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
?.name || 'Form'
setTitle(`${workflowName} Form`)
}
}, [existingForm, isLoading])
fetchExistingForm()
}, [workflowId, onExistingFormChange])
// Get input fields from start block and initialize field configs
useEffect(() => {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const startBlock = blocks.find((b) => b.type === 'starter' || b.type === 'start_trigger')
@@ -202,7 +153,6 @@ export function FormDeploy({
if (inputFormat && Array.isArray(inputFormat)) {
setInputFields(inputFormat)
// Initialize field configs if not already set
if (fieldConfigs.length === 0) {
setFieldConfigs(
inputFormat.map((f: { name: string; type?: string }) => ({
@@ -222,7 +172,6 @@ export function FormDeploy({
const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
// Validate form
useEffect(() => {
const isValid =
inputFields.length > 0 &&
@@ -253,7 +202,6 @@ export function FormDeploy({
e.preventDefault()
setErrors({})
// Validate before submit
if (!isIdentifierValid && identifier !== existingForm?.identifier) {
setError('identifier', 'Please wait for identifier validation to complete')
return
@@ -281,17 +229,21 @@ export function FormDeploy({
try {
if (existingForm) {
await updateForm(existingForm.id, {
identifier,
title,
description,
customizations,
authType,
password: password || undefined,
allowedEmails,
await updateFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
data: {
identifier,
title,
description,
customizations,
authType,
password: password || undefined,
allowedEmails,
},
})
} else {
const result = await createForm({
const result = await createFormMutation.mutateAsync({
workflowId,
identifier,
title,
@@ -304,7 +256,6 @@ export function FormDeploy({
if (result?.formUrl) {
setFormUrl(result.formUrl)
// Open the form in a new window after successful deployment
window.open(result.formUrl, '_blank', 'noopener,noreferrer')
}
}
@@ -318,7 +269,6 @@ export function FormDeploy({
const message = err instanceof Error ? err.message : 'An error occurred'
logger.error('Error deploying form:', err)
// Parse error message and show inline
if (message.toLowerCase().includes('identifier')) {
setError('identifier', message)
} else if (message.toLowerCase().includes('password')) {
@@ -342,8 +292,8 @@ export function FormDeploy({
password,
allowedEmails,
isIdentifierValid,
createForm,
updateForm,
createFormMutation,
updateFormMutation,
onDeployed,
onDeploymentComplete,
]
@@ -353,9 +303,10 @@ export function FormDeploy({
if (!existingForm) return
try {
await deleteForm(existingForm.id)
setExistingForm(null)
onExistingFormChange?.(false)
await deleteFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
})
setIdentifier('')
setTitle('')
setDescription('')
@@ -363,7 +314,7 @@ export function FormDeploy({
} catch (err) {
logger.error('Error deleting form:', err)
}
}, [existingForm, deleteForm, onExistingFormChange])
}, [existingForm, deleteFormMutation, workflowId])
if (isLoading) {
return (

View File

@@ -1,151 +0,0 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('useFormDeployment')
interface CreateFormParams {
workflowId: string
identifier: string
title: string
description?: string
customizations?: {
primaryColor?: string
welcomeMessage?: string
thankYouTitle?: string
thankYouMessage?: string
logoUrl?: string
}
authType?: 'public' | 'password' | 'email'
password?: string
allowedEmails?: string[]
showBranding?: boolean
}
interface UpdateFormParams {
identifier?: string
title?: string
description?: string
customizations?: {
primaryColor?: string
welcomeMessage?: string
thankYouTitle?: string
thankYouMessage?: string
logoUrl?: string
}
authType?: 'public' | 'password' | 'email'
password?: string
allowedEmails?: string[]
showBranding?: boolean
isActive?: boolean
}
interface CreateFormResult {
id: string
formUrl: string
}
export function useFormDeployment() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const createForm = useCallback(
async (params: CreateFormParams): Promise<CreateFormResult | null> => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch('/api/form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create form')
}
logger.info('Form created successfully:', { id: data.id })
return {
id: data.id,
formUrl: data.formUrl,
}
} catch (err: any) {
const errorMessage = err.message || 'Failed to create form'
setError(errorMessage)
logger.error('Error creating form:', err)
throw err
} finally {
setIsSubmitting(false)
}
},
[]
)
const updateForm = useCallback(async (formId: string, params: UpdateFormParams) => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update form')
}
logger.info('Form updated successfully:', { id: formId })
} catch (err: any) {
const errorMessage = err.message || 'Failed to update form'
setError(errorMessage)
logger.error('Error updating form:', err)
throw err
} finally {
setIsSubmitting(false)
}
}, [])
const deleteForm = useCallback(async (formId: string) => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete form')
}
logger.info('Form deleted successfully:', { id: formId })
} catch (err: any) {
const errorMessage = err.message || 'Failed to delete form'
setError(errorMessage)
logger.error('Error deleting form:', err)
throw err
} finally {
setIsSubmitting(false)
}
}, [])
return {
createForm,
updateForm,
deleteForm,
isSubmitting,
error,
}
}

View File

@@ -43,7 +43,6 @@ interface McpDeployProps {
onAddedToServer?: () => void
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
onHasServersChange?: (hasServers: boolean) => void
}
/**
@@ -92,7 +91,6 @@ export function McpDeploy({
onAddedToServer,
onSubmittingChange,
onCanSaveChange,
onHasServersChange,
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -257,10 +255,6 @@ export function McpDeploy({
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
useEffect(() => {
onHasServersChange?.(servers.length > 0)
}, [servers.length, onHasServersChange])
/**
* Save tool configuration to all deployed servers
*/

View File

@@ -20,6 +20,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import {
useCreateTemplate,
useDeleteTemplate,
@@ -47,26 +48,11 @@ const initialFormData: TemplateFormData = {
tags: [],
}
interface CreatorOption {
id: string
name: string
referenceType: 'user' | 'organization'
referenceId: string
}
interface TemplateStatus {
status: 'pending' | 'approved' | 'rejected' | null
views?: number
stars?: number
}
interface TemplateDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
onExistingTemplateChange?: (exists: boolean) => void
onTemplateStatusChange?: (status: TemplateStatus | null) => void
}
export function TemplateDeploy({
@@ -74,13 +60,9 @@ export function TemplateDeploy({
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
onExistingTemplateChange,
onTemplateStatusChange,
}: TemplateDeployProps) {
const { data: session } = useSession()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [isCapturing, setIsCapturing] = useState(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const ogCaptureRef = useRef<HTMLDivElement>(null)
@@ -88,6 +70,7 @@ export function TemplateDeploy({
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const { data: creatorProfiles = [], isLoading: loadingCreators } = useCreatorProfiles()
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
@@ -112,63 +95,15 @@ export function TemplateDeploy({
}, [isSubmitting, onSubmittingChange])
useEffect(() => {
onExistingTemplateChange?.(!!existingTemplate)
}, [existingTemplate, onExistingTemplateChange])
useEffect(() => {
if (existingTemplate) {
onTemplateStatusChange?.({
status: existingTemplate.status as 'pending' | 'approved' | 'rejected',
views: existingTemplate.views,
stars: existingTemplate.stars,
})
} else {
onTemplateStatusChange?.(null)
if (creatorProfiles.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorProfiles[0].id)
logger.info('Auto-selected single creator profile:', creatorProfiles[0].name)
}
}, [existingTemplate, onTemplateStatusChange])
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
setLoadingCreators(true)
try {
const response = await fetch('/api/creators')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
return profiles
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
}
return []
}
}, [creatorProfiles, formData.creatorId])
useEffect(() => {
fetchCreatorOptions()
}, [session?.user?.id])
useEffect(() => {
if (creatorOptions.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorOptions[0].id)
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
}
}, [creatorOptions, formData.creatorId])
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
await fetchCreatorOptions()
const handleCreatorProfileSaved = () => {
logger.info('Creator profile saved, reopening deploy modal...')
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
@@ -357,7 +292,7 @@ export function TemplateDeploy({
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Creator <span className='text-[var(--text-error)]'>*</span>
</Label>
{creatorOptions.length === 0 && !loadingCreators ? (
{creatorProfiles.length === 0 && !loadingCreators ? (
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
A creator profile is required to publish templates.
@@ -385,9 +320,9 @@ export function TemplateDeploy({
</div>
) : (
<Combobox
options={creatorOptions.map((option) => ({
label: option.name,
value: option.id,
options={creatorProfiles.map((profile) => ({
label: profile.name,
value: profile.id,
}))}
value={formData.creatorId}
selectedValue={formData.creatorId}

View File

@@ -1,7 +1,8 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import {
Badge,
Button,
@@ -17,11 +18,22 @@ import {
} from '@/components/emcn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
import { startsWithUuid } from '@/executor/constants'
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
import { useApiKeys } from '@/hooks/queries/api-keys'
import {
deploymentKeys,
useActivateDeploymentVersion,
useChatDeploymentInfo,
useDeploymentInfo,
useDeploymentVersions,
useDeployWorkflow,
useUndeployWorkflow,
} from '@/hooks/queries/deployments'
import { useTemplateByWorkflow } from '@/hooks/queries/templates'
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
@@ -48,7 +60,7 @@ interface DeployModalProps {
refetchDeployedState: () => Promise<void>
}
interface WorkflowDeploymentInfo {
interface WorkflowDeploymentInfoUI {
isDeployed: boolean
deployedAt?: string
apiKey: string
@@ -69,16 +81,12 @@ export function DeployModal({
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
const queryClient = useQueryClient()
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
@@ -86,33 +94,18 @@ export function DeployModal({
const [activeTab, setActiveTab] = useState<TabView>('general')
const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [chatExists, setChatExists] = useState(false)
const [isChatFormValid, setIsChatFormValid] = useState(false)
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
const [versions, setVersions] = useState<WorkflowDeploymentVersionResponse[]>([])
const [versionsLoading, setVersionsLoading] = useState(false)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [templateFormValid, setTemplateFormValid] = useState(false)
const [templateSubmitting, setTemplateSubmitting] = useState(false)
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [hasMcpServers, setHasMcpServers] = useState(false)
const [a2aSubmitting, setA2aSubmitting] = useState(false)
const [a2aCanSave, setA2aCanSave] = useState(false)
const [hasA2aAgent, setHasA2aAgent] = useState(false)
const [isA2aPublished, setIsA2aPublished] = useState(false)
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
const [showA2aDeleteConfirm, setShowA2aDeleteConfirm] = useState(false)
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
const [templateStatus, setTemplateStatus] = useState<{
status: 'pending' | 'approved' | 'rejected' | null
views?: number
stars?: number
} | null>(null)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
const [isLoadingChat, setIsLoadingChat] = useState(false)
const [chatSuccess, setChatSuccess] = useState(false)
@@ -133,193 +126,107 @@ export function DeployModal({
const createButtonDisabled =
isApiKeysLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
const getApiKeyLabel = (value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
}
const {
data: deploymentInfoData,
isLoading: isLoadingDeploymentInfo,
refetch: refetchDeploymentInfo,
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
const getApiHeaderPlaceholder = () =>
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
const {
data: versionsData,
isLoading: versionsLoading,
refetch: refetchVersions,
} = useDeploymentVersions(workflowId, { enabled: open })
const getInputFormatExample = (includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
}
const {
isLoading: isLoadingChat,
chatExists,
existingChat,
refetch: refetchChatInfo,
} = useChatDeploymentInfo(workflowId, { enabled: open })
const fetchChatDeploymentInfo = useCallback(async () => {
if (!workflowId) return
const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '')
const hasMcpServers = mcpServers.length > 0
try {
setIsLoadingChat(true)
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
const { data: existingA2aAgent } = useA2AAgentByWorkflow(
workflowWorkspaceId || '',
workflowId || ''
)
const hasA2aAgent = !!existingA2aAgent
const isA2aPublished = existingA2aAgent?.isPublished ?? false
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.deployment) {
const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
if (detailResponse.ok) {
const chatDetail = await detailResponse.json()
setExistingChat(chatDetail)
setChatExists(true)
} else {
setExistingChat(null)
setChatExists(false)
}
} else {
setExistingChat(null)
setChatExists(false)
}
} else {
setExistingChat(null)
setChatExists(false)
const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', {
enabled: !!workflowId,
})
const hasExistingTemplate = !!existingTemplate
const templateStatus = existingTemplate
? {
status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null,
views: existingTemplate.views,
stars: existingTemplate.stars,
}
} catch (error) {
logger.error('Error fetching chat deployment info:', { error })
setExistingChat(null)
setChatExists(false)
} finally {
setIsLoadingChat(false)
: null
const deployMutation = useDeployWorkflow()
const undeployMutation = useUndeployWorkflow()
const activateVersionMutation = useActivateDeploymentVersion()
const versions = versionsData?.versions ?? []
const getApiKeyLabel = useCallback(
(value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
},
[workflowWorkspaceId]
)
const getApiHeaderPlaceholder = useCallback(
() => (workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'),
[workflowWorkspaceId]
)
const getInputFormatExample = useCallback(
(includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
},
[selectedStreamingOutputs]
)
const deploymentInfo: WorkflowDeploymentInfoUI | null = useMemo(() => {
if (!deploymentInfoData?.isDeployed || !workflowId) {
return null
}
}, [workflowId])
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
return {
isDeployed: deploymentInfoData.isDeployed,
deployedAt: deploymentInfoData.deployedAt ?? undefined,
apiKey: getApiKeyLabel(deploymentInfoData.apiKey),
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: deploymentInfoData.needsRedeployment,
}
}, [
deploymentInfoData,
workflowId,
selectedStreamingOutputs,
getInputFormatExample,
getApiHeaderPlaceholder,
getApiKeyLabel,
])
useEffect(() => {
if (open && workflowId) {
setActiveTab('general')
setApiDeployError(null)
fetchChatDeploymentInfo()
}
}, [open, workflowId, fetchChatDeploymentInfo])
useEffect(() => {
async function fetchDeploymentInfo() {
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
setIsLoading(false)
return
}
if (deploymentInfo?.isDeployed && !needsRedeployment) {
setIsLoading(false)
return
}
try {
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
if (!response.ok) {
throw new Error('Failed to fetch deployment information')
}
const data = await response.json()
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY'
setDeploymentInfo({
isDeployed: data.isDeployed,
deployedAt: data.deployedAt,
apiKey: data.apiKey || placeholderKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment,
})
} catch (error) {
logger.error('Error fetching deployment info:', { error })
} finally {
setIsLoading(false)
}
}
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
const onDeploy = async () => {
setApiDeployError(null)
try {
setIsSubmitting(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const responseData = await response.json()
const isDeployedStatus = responseData.isDeployed ?? false
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
await refetchDeployedState()
await fetchVersions()
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
setApiDeployError(null)
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setApiDeployError(errorMessage)
} finally {
setIsSubmitting(false)
}
}
const fetchVersions = useCallback(async () => {
if (!workflowId) return
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments`)
if (res.ok) {
const data = await res.json()
setVersions(Array.isArray(data.versions) ? data.versions : [])
} else {
setVersions([])
}
} catch {
setVersions([])
}
}, [workflowId])
useEffect(() => {
if (open && workflowId) {
setVersionsLoading(true)
fetchVersions().finally(() => setVersionsLoading(false))
}
}, [open, workflowId, fetchVersions])
}, [open, workflowId])
useEffect(() => {
if (!open || selectedStreamingOutputs.length === 0) return
@@ -369,181 +276,88 @@ export function DeployModal({
}
}, [onOpenChange])
const onDeploy = useCallback(async () => {
if (!workflowId) return
setApiDeployError(null)
try {
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
await refetchDeployedState()
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setApiDeployError(errorMessage)
}
}, [workflowId, deployMutation, refetchDeployedState])
const handlePromoteToLive = useCallback(
async (version: number) => {
if (!workflowId) return
const previousVersions = [...versions]
setVersions((prev) =>
prev.map((v) => ({
...v,
isActive: v.version === version,
}))
)
try {
const response = await fetch(
`/api/workflows/${workflowId}/deployments/${version}/activate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to promote version')
}
const responseData = await response.json()
const deployedAtTime = responseData.deployedAt
? new Date(responseData.deployedAt)
: undefined
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
refetchDeployedState()
fetchVersions()
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
await activateVersionMutation.mutateAsync({ workflowId, version })
await refetchDeployedState()
} catch (error) {
setVersions(previousVersions)
logger.error('Error promoting version:', { error })
throw error
}
},
[workflowId, versions, refetchDeployedState, fetchVersions, selectedStreamingOutputs]
[workflowId, activateVersionMutation, refetchDeployedState]
)
const handleUndeploy = async () => {
const handleUndeploy = useCallback(async () => {
if (!workflowId) return
try {
setIsUndeploying(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
setDeploymentStatus(workflowId, false)
setChatExists(false)
await undeployMutation.mutateAsync({ workflowId })
setShowUndeployConfirm(false)
onOpenChange(false)
} catch (error: unknown) {
logger.error('Error undeploying workflow:', { error })
} finally {
setIsUndeploying(false)
}
}
}, [workflowId, undeployMutation, onOpenChange])
const handleRedeploy = useCallback(async () => {
if (!workflowId) return
setApiDeployError(null)
const handleRedeploy = async () => {
try {
setIsSubmitting(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to redeploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
setDeploymentStatus(
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
getApiKeyLabel(apiKey)
)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
await refetchDeployedState()
await fetchVersions()
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
} catch (error: unknown) {
logger.error('Error redeploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow'
setApiDeployError(errorMessage)
} finally {
setIsSubmitting(false)
}
}
}, [workflowId, deployMutation, refetchDeployedState])
const handleCloseModal = () => {
setIsSubmitting(false)
const handleCloseModal = useCallback(() => {
setChatSubmitting(false)
setApiDeployError(null)
onOpenChange(false)
}
}, [onOpenChange])
const handleChatDeployed = async () => {
await handlePostDeploymentUpdate()
setChatSuccess(true)
setTimeout(() => setChatSuccess(false), 2000)
}
const handlePostDeploymentUpdate = async () => {
const handleChatDeployed = useCallback(async () => {
if (!workflowId) return
setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) })
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) })
queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(workflowId) })
await refetchDeployedState()
await fetchVersions()
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
const handleChatFormSubmit = () => {
setChatSuccess(true)
setTimeout(() => setChatSuccess(false), 2000)
}, [workflowId, queryClient, refetchDeployedState])
const handleRefetchChat = useCallback(async () => {
await refetchChatInfo()
}, [refetchChatInfo])
const handleChatFormSubmit = useCallback(() => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement
@@ -553,9 +367,9 @@ export function DeployModal({
form.requestSubmit()
}
}
}
}, [])
const handleChatDelete = () => {
const handleChatDelete = useCallback(() => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const deleteButton = form.querySelector('[data-delete-trigger]') as HTMLButtonElement
@@ -563,7 +377,7 @@ export function DeployModal({
deleteButton.click()
}
}
}
}, [])
const handleTemplateFormSubmit = useCallback(() => {
const form = document.getElementById('template-deploy-form') as HTMLFormElement
@@ -623,6 +437,13 @@ export function DeployModal({
deleteTrigger?.click()
}, [])
const handleFetchVersions = useCallback(async () => {
await refetchVersions()
}, [refetchVersions])
const isSubmitting = deployMutation.isPending
const isUndeploying = undeployMutation.isPending
return (
<>
<Modal open={open} onOpenChange={handleCloseModal}>
@@ -670,7 +491,7 @@ export function DeployModal({
versionsLoading={versionsLoading}
onPromoteToLive={handlePromoteToLive}
onLoadDeploymentComplete={handleCloseModal}
fetchVersions={fetchVersions}
fetchVersions={handleFetchVersions}
/>
</ModalTabsContent>
@@ -678,7 +499,7 @@ export function DeployModal({
<ApiDeploy
workflowId={workflowId}
deploymentInfo={deploymentInfo}
isLoading={isLoading}
isLoading={isLoadingDeploymentInfo}
needsRedeployment={needsRedeployment}
apiDeployError={apiDeployError}
getInputFormatExample={getInputFormatExample}
@@ -691,10 +512,9 @@ export function DeployModal({
<ChatDeploy
workflowId={workflowId || ''}
deploymentInfo={deploymentInfo}
existingChat={existingChat}
existingChat={existingChat as ExistingChat | null}
isLoadingChat={isLoadingChat}
onRefetchChat={fetchChatDeploymentInfo}
onChatExistsChange={setChatExists}
onRefetchChat={handleRefetchChat}
chatSubmitting={chatSubmitting}
setChatSubmitting={setChatSubmitting}
onValidationChange={setIsChatFormValid}
@@ -711,8 +531,6 @@ export function DeployModal({
onDeploymentComplete={handleCloseModal}
onValidationChange={setTemplateFormValid}
onSubmittingChange={setTemplateSubmitting}
onExistingTemplateChange={setHasExistingTemplate}
onTemplateStatusChange={setTemplateStatus}
/>
)}
</ModalTabsContent>
@@ -741,7 +559,6 @@ export function DeployModal({
isDeployed={isDeployed}
onSubmittingChange={setMcpToolSubmitting}
onCanSaveChange={setMcpToolCanSave}
onHasServersChange={setHasMcpServers}
/>
)}
</ModalTabsContent>
@@ -756,8 +573,6 @@ export function DeployModal({
workflowNeedsRedeployment={needsRedeployment}
onSubmittingChange={setA2aSubmitting}
onCanSaveChange={setA2aCanSave}
onAgentExistsChange={setHasA2aAgent}
onPublishedChange={setIsA2aPublished}
onNeedsRepublishChange={setA2aNeedsRepublish}
onDeployWorkflow={onDeploy}
/>
@@ -843,7 +658,7 @@ export function DeployModal({
onClick={handleMcpToolFormSubmit}
disabled={mcpToolSubmitting || !mcpToolCanSave}
>
{mcpToolSubmitting ? 'Saving...' : 'Save Tool Schema'}
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
</Button>
</div>
</ModalFooter>

View File

@@ -3,13 +3,17 @@ import { Label } from '@/components/emcn'
interface FormFieldProps {
label: string
children: React.ReactNode
optional?: boolean
}
export function FormField({ label, children }: FormFieldProps) {
export function FormField({ label, children, optional }: FormFieldProps) {
return (
<div className='flex items-center justify-between gap-[12px]'>
<Label className='w-[100px] shrink-0 font-medium text-[13px] text-[var(--text-secondary)]'>
{label}
{optional && (
<span className='ml-1 font-normal text-[11px] text-[var(--text-muted)]'>(optional)</span>
)}
</Label>
<div className='relative flex-1'>{children}</div>
</div>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search, X } from 'lucide-react'
import { ChevronDown, Plus, Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -77,10 +77,17 @@ interface EnvVarDropdownConfig {
onClose: () => void
}
interface McpToolSchema {
type: 'object'
properties?: Record<string, unknown>
required?: string[]
}
interface McpTool {
name: string
description?: string
serverId: string
inputSchema?: McpToolSchema
}
interface McpServer {
@@ -381,6 +388,7 @@ export function MCP({ initialServerId }: MCPProps) {
const [refreshingServers, setRefreshingServers] = useState<
Record<string, { status: 'refreshing' | 'refreshed'; workflowsUpdated?: number }>
>({})
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('')
@@ -669,6 +677,22 @@ export function MCP({ initialServerId }: MCPProps) {
*/
const handleBackToList = useCallback(() => {
setSelectedServerId(null)
setExpandedTools(new Set())
}, [])
/**
* Toggles the expanded state of a tool's parameters.
*/
const toggleToolExpanded = useCallback((toolName: string) => {
setExpandedTools((prev) => {
const newSet = new Set(prev)
if (newSet.has(toolName)) {
newSet.delete(toolName)
} else {
newSet.add(toolName)
}
return newSet
})
}, [])
/**
@@ -843,38 +867,113 @@ export function MCP({ initialServerId }: MCPProps) {
{tools.map((tool) => {
const issues = getStoredToolIssues(server.id, tool.name)
const affectedWorkflows = issues.map((i) => i.workflowName)
const isExpanded = expandedTools.has(tool.name)
const hasParams =
tool.inputSchema?.properties &&
Object.keys(tool.inputSchema.properties).length > 0
const requiredParams = tool.inputSchema?.required || []
return (
<div
key={tool.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
className='overflow-hidden rounded-[6px] border bg-[var(--surface-3)]'
>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
<button
type='button'
onClick={() => hasParams && toggleToolExpanded(tool.name)}
className={cn(
'flex w-full items-start justify-between px-[10px] py-[8px] text-left',
hasParams && 'cursor-pointer hover:bg-[var(--surface-4)]'
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
disabled={!hasParams}
>
<div className='flex-1'>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
{hasParams && (
<ChevronDown
className={cn(
'mt-[2px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
)}
</button>
{isExpanded && hasParams && (
<div className='border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] py-[8px]'>
<p className='mb-[6px] font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
Parameters
</p>
<div className='flex flex-col gap-[6px]'>
{Object.entries(tool.inputSchema!.properties!).map(
([paramName, param]) => {
const isRequired = requiredParams.includes(paramName)
const paramType =
typeof param === 'object' && param !== null
? (param as { type?: string }).type || 'any'
: 'any'
const paramDesc =
typeof param === 'object' && param !== null
? (param as { description?: string }).description
: undefined
return (
<div
key={paramName}
className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-3)] px-[8px] py-[6px]'
>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
{paramName}
</span>
<Badge variant='outline' size='sm'>
{paramType}
</Badge>
{isRequired && (
<Badge variant='default' size='sm'>
required
</Badge>
)}
</div>
{paramDesc && (
<p className='mt-[3px] text-[11px] text-[var(--text-tertiary)] leading-relaxed'>
{paramDesc}
</p>
)}
</div>
)
}
)}
</div>
</div>
)}
</div>
)

View File

@@ -7,6 +7,9 @@ import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Code,
Combobox,
type ComboboxOption,
Input as EmcnInput,
@@ -16,22 +19,33 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
SModalTabs,
SModalTabsBody,
SModalTabsContent,
SModalTabsList,
SModalTabsTrigger,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useApiKeys } from '@/hooks/queries/api-keys'
import {
useAddWorkflowMcpTool,
useCreateWorkflowMcpServer,
useDeleteWorkflowMcpServer,
useDeleteWorkflowMcpTool,
useDeployedWorkflows,
useUpdateWorkflowMcpServer,
useUpdateWorkflowMcpTool,
useWorkflowMcpServer,
useWorkflowMcpServers,
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { CreateApiKeyModal } from '../api-keys/components'
import { FormField, McpServerSkeleton } from '../mcp/components'
const logger = createLogger('WorkflowMcpServers')
@@ -42,22 +56,63 @@ interface ServerDetailViewProps {
onBack: () => void
}
type McpClientType = 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode'
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
const { data, isLoading, error, refetch } = useWorkflowMcpServer(workspaceId, serverId)
const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const deleteToolMutation = useDeleteWorkflowMcpTool()
const addToolMutation = useAddWorkflowMcpTool()
const updateToolMutation = useUpdateWorkflowMcpTool()
const [copiedUrl, setCopiedUrl] = useState(false)
const updateServerMutation = useUpdateWorkflowMcpServer()
// API Keys - for "Create API key" link
const { data: apiKeysData } = useApiKeys(workspaceId)
const { data: workspaceSettingsData } = useWorkspaceSettings(workspaceId)
const userPermissions = useUserPermissionsContext()
const [showCreateApiKeyModal, setShowCreateApiKeyModal] = useState(false)
const existingKeyNames = [
...(apiKeysData?.workspaceKeys ?? []),
...(apiKeysData?.personalKeys ?? []),
].map((k) => k.name)
const allowPersonalApiKeys =
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
const canManageWorkspaceKeys = userPermissions.canAdmin
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const [copiedConfig, setCopiedConfig] = useState(false)
const [activeConfigTab, setActiveConfigTab] = useState<McpClientType>('cursor')
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
const [toolToView, setToolToView] = useState<WorkflowMcpTool | null>(null)
const [editingDescription, setEditingDescription] = useState<string>('')
const [editingParameterDescriptions, setEditingParameterDescriptions] = useState<
Record<string, string>
>({})
const [showAddWorkflow, setShowAddWorkflow] = useState(false)
const [showEditServer, setShowEditServer] = useState(false)
const [editServerName, setEditServerName] = useState('')
const [editServerDescription, setEditServerDescription] = useState('')
const [editServerIsPublic, setEditServerIsPublic] = useState(false)
const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details')
useEffect(() => {
if (toolToView) {
setEditingDescription(toolToView.toolDescription || '')
const schema = toolToView.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
if (properties) {
const descriptions: Record<string, string> = {}
for (const [name, prop] of Object.entries(properties)) {
descriptions[name] = prop.description || ''
}
setEditingParameterDescriptions(descriptions)
} else {
setEditingParameterDescriptions({})
}
}
}, [toolToView])
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null)
@@ -66,12 +121,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return `${getBaseUrl()}/api/mcp/serve/${serverId}`
}, [serverId])
const handleCopyUrl = () => {
navigator.clipboard.writeText(mcpServerUrl)
setCopiedUrl(true)
setTimeout(() => setCopiedUrl(false), 2000)
}
const handleDeleteTool = async () => {
if (!toolToDelete) return
try {
@@ -96,7 +145,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
})
setShowAddWorkflow(false)
setSelectedWorkflowId(null)
refetch()
setActiveServerTab('workflows')
} catch (err) {
logger.error('Failed to add workflow:', err)
}
@@ -108,6 +157,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
const existingWorkflowIds = new Set(tools.map((t) => t.workflowId))
return deployedWorkflows.filter((w) => !existingWorkflowIds.has(w.id))
}, [deployedWorkflows, tools])
const canAddWorkflow = availableWorkflows.length > 0
const showAddDisabledTooltip = !canAddWorkflow && deployedWorkflows.length > 0
const workflowOptions: ComboboxOption[] = useMemo(() => {
return availableWorkflows.map((w) => ({
@@ -120,6 +171,115 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return availableWorkflows.find((w) => w.id === selectedWorkflowId)
}, [availableWorkflows, selectedWorkflowId])
const getConfigSnippet = useCallback(
(client: McpClientType, isPublic: boolean, serverName: string): string => {
const safeName = serverName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (client === 'claude-code') {
if (isPublic) {
return `claude mcp add "${safeName}" --url "${mcpServerUrl}"`
}
return `claude mcp add "${safeName}" --url "${mcpServerUrl}" --header "X-API-Key:$SIM_API_KEY"`
}
const mcpRemoteArgs = isPublic
? ['-y', 'mcp-remote', mcpServerUrl]
: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY']
const baseServerConfig = {
command: 'npx',
args: mcpRemoteArgs,
}
if (client === 'vscode') {
return JSON.stringify(
{
servers: {
[safeName]: {
type: 'stdio',
...baseServerConfig,
},
},
},
null,
2
)
}
return JSON.stringify(
{
mcpServers: {
[safeName]: baseServerConfig,
},
},
null,
2
)
},
[mcpServerUrl]
)
const handleCopyConfig = useCallback(
(isPublic: boolean, serverName: string) => {
const snippet = getConfigSnippet(activeConfigTab, isPublic, serverName)
navigator.clipboard.writeText(snippet)
setCopiedConfig(true)
setTimeout(() => setCopiedConfig(false), 2000)
},
[activeConfigTab, getConfigSnippet]
)
const handleOpenEditServer = useCallback(() => {
if (data?.server) {
setEditServerName(data.server.name)
setEditServerDescription(data.server.description || '')
setEditServerIsPublic(data.server.isPublic)
setShowEditServer(true)
}
}, [data?.server])
const handleSaveServerEdit = async () => {
if (!editServerName.trim()) return
try {
await updateServerMutation.mutateAsync({
workspaceId,
serverId,
name: editServerName.trim(),
description: editServerDescription.trim() || undefined,
isPublic: editServerIsPublic,
})
setShowEditServer(false)
} catch (err) {
logger.error('Failed to update server:', err)
}
}
const getCursorInstallUrl = useCallback(
(isPublic: boolean, serverName: string): string => {
const safeName = serverName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
const config = isPublic
? {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl],
}
: {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'],
}
const base64Config = btoa(JSON.stringify(config))
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(safeName)}&config=${encodeURIComponent(base64Config)}`
},
[mcpServerUrl]
)
if (isLoading) {
return (
<div className='flex h-full flex-col gap-[16px]'>
@@ -148,97 +308,223 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Server Name
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
</div>
<SModalTabs
value={activeServerTab}
onValueChange={(value) => setActiveServerTab(value as 'workflows' | 'details')}
className='flex min-h-0 flex-1 flex-col'
>
<SModalTabsList activeValue={activeServerTab}>
<SModalTabsTrigger value='details'>Details</SModalTabsTrigger>
<SModalTabsTrigger value='workflows'>Workflows</SModalTabsTrigger>
</SModalTabsList>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Transport</span>
<p className='text-[14px] text-[var(--text-secondary)]'>Streamable-HTTP</p>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<div className='flex items-center gap-[8px]'>
<p className='flex-1 break-all text-[14px] text-[var(--text-secondary)]'>
{mcpServerUrl}
</p>
<Button variant='ghost' onClick={handleCopyUrl} className='h-[32px] w-[32px] p-0'>
{copiedUrl ? (
<Check className='h-[14px] w-[14px]' />
<SModalTabsBody>
<SModalTabsContent value='workflows'>
<div className='flex flex-col gap-[16px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Workflows
</span>
{showAddDisabledTooltip ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='inline-flex'>
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
All deployed workflows have been added to this server.
</Tooltip.Content>
</Tooltip.Root>
) : (
<Clipboard className='h-[14px] w-[14px]' />
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled={!canAddWorkflow}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
)}
</Button>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Workflows ({tools.length})
</span>
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled={availableWorkflows.length === 0}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
{tools.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>
No workflows added yet. Click "Add" to add a deployed workflow.
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{tool.toolName}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.toolDescription || 'No description'}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='default' onClick={() => setToolToView(tool)}>
Details
</Button>
<Button
variant='ghost'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
{availableWorkflows.length === 0 && deployedWorkflows.length > 0 && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
All deployed workflows have been added to this server.
</p>
)}
{deployedWorkflows.length === 0 && !isLoadingWorkflows && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
Deploy a workflow first to add it to this server.
</p>
)}
</div>
{tools.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>
No workflows added yet. Click "Add" to add a deployed workflow.
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{tool.toolName}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.toolDescription || 'No description'}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='default' onClick={() => setToolToView(tool)}>
Edit
</Button>
<Button
variant='ghost'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
{deployedWorkflows.length === 0 && !isLoadingWorkflows && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
Deploy a workflow first to add it to this server.
</p>
)}
</div>
</SModalTabsContent>
<SModalTabsContent value='details'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Server Name
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
</div>
{server.description?.trim() && (
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.description}</p>
</div>
)}
<div className='flex gap-[24px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Transport
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>Streamable-HTTP</p>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Access
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>
{server.isPublic ? 'Public' : 'API Key'}
</p>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<p className='break-all text-[14px] text-[var(--text-secondary)]'>
{mcpServerUrl}
</p>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<span className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
MCP Client
</span>
</div>
<ButtonGroup
value={activeConfigTab}
onValueChange={(v) => setActiveConfigTab(v as McpClientType)}
>
<ButtonGroupItem value='cursor'>Cursor</ButtonGroupItem>
<ButtonGroupItem value='claude-code'>Claude Code</ButtonGroupItem>
<ButtonGroupItem value='claude-desktop'>Claude Desktop</ButtonGroupItem>
<ButtonGroupItem value='vscode'>VS Code</ButtonGroupItem>
</ButtonGroup>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<span className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Configuration
</span>
<Button
variant='ghost'
onClick={() => handleCopyConfig(server.isPublic, server.name)}
className='!p-1.5 -my-1.5'
>
{copiedConfig ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</div>
<div className='relative'>
<Code.Viewer
code={getConfigSnippet(activeConfigTab, server.isPublic, server.name)}
language={activeConfigTab === 'claude-code' ? 'javascript' : 'json'}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
{activeConfigTab === 'cursor' && (
<a
href={getCursorInstallUrl(server.isPublic, server.name)}
className='absolute top-[6px] right-2'
>
<img
src='https://cursor.com/deeplink/mcp-install-dark.svg'
alt='Add to Cursor'
className='h-[26px]'
/>
</a>
)}
</div>
{!server.isPublic && (
<p className='mt-[8px] text-[11px] text-[var(--text-muted)]'>
Replace $SIM_API_KEY with your API key, or{' '}
<button
type='button'
onClick={() => setShowCreateApiKeyModal(true)}
className='underline hover:text-[var(--text-secondary)]'
>
create one now
</button>
</p>
)}
</div>
</div>
</SModalTabsContent>
</SModalTabsBody>
</SModalTabs>
<div className='mt-auto flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
{activeServerTab === 'details' && (
<>
<Button onClick={handleOpenEditServer} variant='default'>
Edit Server
</Button>
<Button
onClick={() => setShowAddWorkflow(true)}
variant='default'
disabled={!canAddWorkflow}
>
Add Workflows
</Button>
</>
)}
</div>
</div>
<div className='mt-auto flex items-center justify-end'>
<Button onClick={onBack} variant='tertiary'>
Back
</Button>
@@ -278,6 +564,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
if (!open) {
setToolToView(null)
setEditingDescription('')
setEditingParameterDescriptions({})
}
}}
>
@@ -285,10 +572,10 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
<ModalHeader>{toolToView?.toolName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
</Label>
<Textarea
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
@@ -297,44 +584,58 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
/>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Parameters
</span>
{(() => {
const schema = toolToView?.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
if (!properties || Object.keys(properties).length === 0) {
return <p className='text-[13px] text-[var(--text-muted)]'>No parameters</p>
}
return (
<div className='flex flex-col gap-[8px]'>
{Object.entries(properties).map(([name, prop]) => (
<div
key={name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{name}
</span>
<Badge variant='outline' size='sm'>
{prop.type || 'any'}
</Badge>
{(() => {
const schema = toolToView?.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
const hasParams = properties && Object.keys(properties).length > 0
return (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Parameters
</Label>
{hasParams ? (
<div className='flex flex-col gap-[8px]'>
{Object.entries(properties).map(([name, prop]) => (
<div
key={name}
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{name}
</span>
<Badge size='sm'>{prop.type || 'any'}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<EmcnInput
value={editingParameterDescriptions[name] || ''}
onChange={(e) =>
setEditingParameterDescriptions((prev) => ({
...prev,
[name]: e.target.value,
}))
}
placeholder={`Enter description for ${name}`}
/>
</div>
</div>
</div>
{prop.description && (
<p className='mt-[4px] text-[12px] text-[var(--text-muted)]'>
{prop.description}
</p>
)}
</div>
))}
</div>
)
})()}
</div>
))}
</div>
) : (
<p className='text-[13px] text-[var(--text-muted)]'>
No inputs configured for this workflow.
</p>
)}
</div>
)
})()}
</div>
</ModalBody>
<ModalFooter>
@@ -346,23 +647,59 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
onClick={async () => {
if (!toolToView) return
try {
const currentSchema = toolToView.parameterSchema as Record<string, unknown>
const currentProperties = (currentSchema?.properties || {}) as Record<
string,
{ type?: string; description?: string }
>
const updatedProperties: Record<string, { type?: string; description?: string }> =
{}
for (const [name, prop] of Object.entries(currentProperties)) {
updatedProperties[name] = {
...prop,
description: editingParameterDescriptions[name]?.trim() || undefined,
}
}
const updatedSchema = {
...currentSchema,
properties: updatedProperties,
}
await updateToolMutation.mutateAsync({
workspaceId,
serverId,
toolId: toolToView.id,
toolDescription: editingDescription.trim() || undefined,
parameterSchema: updatedSchema,
})
refetch()
setToolToView(null)
setEditingDescription('')
setEditingParameterDescriptions({})
} catch (err) {
logger.error('Failed to update tool description:', err)
logger.error('Failed to update tool:', err)
}
}}
disabled={
updateToolMutation.isPending ||
editingDescription.trim() === (toolToView?.toolDescription || '')
}
disabled={(() => {
if (updateToolMutation.isPending) return true
if (!toolToView) return true
const descriptionChanged =
editingDescription.trim() !== (toolToView.toolDescription || '')
const schema = toolToView.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties || {}
const paramDescriptionsChanged = Object.keys(properties).some((name) => {
const original = properties[name]?.description || ''
const edited = editingParameterDescriptions[name]?.trim() || ''
return original !== edited
})
return !descriptionChanged && !paramDescriptionsChanged
})()}
>
{updateToolMutation.isPending ? 'Saving...' : 'Save'}
</Button>
@@ -435,6 +772,83 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</ModalFooter>
</ModalContent>
</Modal>
<Modal
open={showEditServer}
onOpenChange={(open) => {
if (!open) {
setShowEditServer(false)
}
}}
>
<ModalContent className='w-[420px]'>
<ModalHeader>Edit Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={editServerName}
onChange={(e) => setEditServerName(e.target.value)}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={editServerDescription}
onChange={(e) => setEditServerDescription(e.target.value)}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Access'>
<ButtonGroup
value={editServerIsPublic ? 'public' : 'private'}
onValueChange={(value) => setEditServerIsPublic(value === 'public')}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
</FormField>
<p className='text-[11px] text-[var(--text-muted)]'>
{editServerIsPublic
? 'Anyone with the URL can call this server without authentication'
: 'Requests must include your Sim API key in the X-API-Key header'}
</p>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowEditServer(false)}>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleSaveServerEdit}
disabled={
!editServerName.trim() ||
updateServerMutation.isPending ||
(editServerName === server.name &&
editServerDescription === (server.description || '') &&
editServerIsPublic === server.isPublic)
}
>
{updateServerMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<CreateApiKeyModal
open={showCreateApiKeyModal}
onOpenChange={setShowCreateApiKeyModal}
workspaceId={workspaceId}
existingKeyNames={existingKeyNames}
allowPersonalApiKeys={allowPersonalApiKeys}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
</>
)
}
@@ -448,12 +862,15 @@ export function WorkflowMcpServers() {
const workspaceId = params.workspaceId as string
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const createServerMutation = useCreateWorkflowMcpServer()
const deleteServerMutation = useDeleteWorkflowMcpServer()
const [searchTerm, setSearchTerm] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [formData, setFormData] = useState({ name: '' })
const [formData, setFormData] = useState({ name: '', description: '', isPublic: false })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
@@ -464,8 +881,16 @@ export function WorkflowMcpServers() {
return servers.filter((server) => server.name.toLowerCase().includes(search))
}, [servers, searchTerm])
const workflowOptions: ComboboxOption[] = useMemo(() => {
return deployedWorkflows.map((w) => ({
label: w.name,
value: w.id,
}))
}, [deployedWorkflows])
const resetForm = useCallback(() => {
setFormData({ name: '' })
setFormData({ name: '', description: '', isPublic: false })
setSelectedWorkflowIds([])
setShowAddForm(false)
}, [])
@@ -476,6 +901,9 @@ export function WorkflowMcpServers() {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
resetForm()
} catch (err) {
@@ -544,17 +972,68 @@ export function WorkflowMcpServers() {
{shouldShowForm && !isLoading && (
<div className='rounded-[8px] border p-[10px]'>
<div className='flex flex-col gap-[8px]'>
<div className='flex flex-col gap-[12px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ name: e.target.value })}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className='h-9'
/>
</FormField>
<div className='flex items-center justify-end gap-[8px] pt-[12px]'>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Workflows'>
<Combobox
options={workflowOptions}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
isLoading={isLoadingWorkflows}
disabled={createServerMutation.isPending}
emptyMessage='No deployed workflows available'
overlayContent={
selectedWorkflowIds.length > 0 ? (
<span className='text-[var(--text-primary)]'>
{selectedWorkflowIds.length} workflow
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
</span>
) : undefined
}
/>
</FormField>
<FormField label='Access'>
<div className='flex items-center gap-[12px]'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) =>
setFormData({ ...formData, isPublic: value === 'public' })
}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[11px] text-[var(--text-muted)]'>
No authentication required
</span>
)}
</div>
</FormField>
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
<Button variant='ghost' onClick={resetForm}>
Cancel
</Button>
@@ -587,9 +1066,7 @@ export function WorkflowMcpServers() {
<div className='flex flex-col gap-[8px]'>
{filteredServers.map((server) => {
const count = server.toolCount || 0
const toolNames = server.toolNames || []
const names = count > 0 ? `: ${toolNames.join(', ')}` : ''
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}${names}`
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}`
const isDeleting = deletingServers.has(server.id)
return (
<div key={server.id} className='flex items-center justify-between gap-[12px]'>
@@ -598,9 +1075,11 @@ export function WorkflowMcpServers() {
<span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(Streamable-HTTP)
</span>
{server.isPublic && (
<Badge variant='outline' size='sm'>
Public
</Badge>
)}
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>{toolsLabel}</p>
</div>

View File

@@ -93,6 +93,11 @@ export {
type SModalSidebarItemProps,
SModalSidebarSection,
SModalSidebarSectionTitle,
SModalTabs,
SModalTabsBody,
SModalTabsContent,
SModalTabsList,
SModalTabsTrigger,
SModalTrigger,
} from './s-modal/s-modal'
export { Slider, type SliderProps } from './slider/slider'

View File

@@ -26,7 +26,7 @@ import { cn } from '@/lib/core/utils/cn'
* Currently supports a 'default' variant.
*/
const inputVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] font-medium font-sans text-sm text-foreground transition-colors placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -26,6 +26,7 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { X } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
import { Button } from '../button/button'
@@ -211,7 +212,7 @@ const SModalMain = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
<div
ref={ref}
className={cn(
'flex min-w-0 flex-1 flex-col gap-[16px] rounded-[8px] border-l bg-[var(--surface-2)] p-[14px]',
'flex min-w-0 flex-1 flex-col gap-[16px] overflow-hidden rounded-[8px] border-l bg-[var(--surface-2)] p-[14px]',
className
)}
{...props}
@@ -245,12 +246,146 @@ SModalMainHeader.displayName = 'SModalMainHeader'
*/
const SModalMainBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('min-w-0 flex-1 overflow-y-auto', className)} {...props} />
<div
ref={ref}
className={cn('min-w-0 flex-1 overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
)
)
SModalMainBody.displayName = 'SModalMainBody'
/**
* Sidebar modal tabs root component.
*/
const SModalTabs = TabsPrimitive.Root
interface SModalTabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> {
/** Currently active tab value for indicator positioning */
activeValue?: string
/**
* Whether the tabs are disabled (non-interactive with reduced opacity)
* @default false
*/
disabled?: boolean
}
/**
* Sidebar modal tabs list component with animated indicator.
*/
const SModalTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
SModalTabsListProps
>(({ className, children, activeValue, disabled = false, ...props }, ref) => {
const listRef = React.useRef<HTMLDivElement>(null)
const [indicator, setIndicator] = React.useState({ left: 0, width: 0 })
const [ready, setReady] = React.useState(false)
React.useEffect(() => {
const list = listRef.current
if (!list) return
const updateIndicator = () => {
const activeTab = list.querySelector('[data-state="active"]') as HTMLElement | null
if (!activeTab) return
setIndicator({
left: activeTab.offsetLeft,
width: activeTab.offsetWidth,
})
setReady(true)
}
updateIndicator()
const observer = new MutationObserver(updateIndicator)
observer.observe(list, { attributes: true, subtree: true, attributeFilter: ['data-state'] })
window.addEventListener('resize', updateIndicator)
return () => {
observer.disconnect()
window.removeEventListener('resize', updateIndicator)
}
}, [activeValue])
return (
<TabsPrimitive.List
ref={ref}
className={cn(
'relative flex gap-[16px] px-4',
disabled && 'pointer-events-none opacity-50',
className
)}
{...props}
>
<div ref={listRef} className='flex gap-[16px]'>
{children}
</div>
<span
className={cn(
'pointer-events-none absolute bottom-0 h-[1px] rounded-full bg-[var(--text-primary)]',
ready && 'transition-all duration-200 ease-out'
)}
style={{ left: indicator.left, width: indicator.width }}
/>
</TabsPrimitive.List>
)
})
SModalTabsList.displayName = 'SModalTabsList'
/**
* Sidebar modal tab trigger component.
*/
const SModalTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'px-1 pb-[8px] font-medium text-[13px] text-[var(--text-secondary)] transition-colors',
'hover:text-[var(--text-primary)] data-[state=active]:text-[var(--text-primary)]',
className
)}
{...props}
/>
))
SModalTabsTrigger.displayName = 'SModalTabsTrigger'
/**
* Sidebar modal tab content component.
*/
const SModalTabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn('pb-[10px]', className)} {...props} />
))
SModalTabsContent.displayName = 'SModalTabsContent'
/**
* Sidebar modal tabs body container with border-top divider.
* Wraps tab content panels to provide consistent styling with ModalBody.
*/
const SModalTabsBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'min-h-0 flex-1 overflow-y-auto border-[var(--border)] border-t pt-[10px]',
className
)}
{...props}
/>
)
)
SModalTabsBody.displayName = 'SModalTabsBody'
export {
SModal,
SModalTrigger,
@@ -264,4 +399,9 @@ export {
SModalMain,
SModalMainHeader,
SModalMainBody,
SModalTabs,
SModalTabsList,
SModalTabsTrigger,
SModalTabsContent,
SModalTabsBody,
}

View File

@@ -0,0 +1,262 @@
import { createLogger } from '@sim/logger'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { OutputConfig } from '@/stores/chat/types'
import { deploymentKeys } from './deployments'
const logger = createLogger('ChatMutations')
/**
* Query keys for chat-related queries
*/
export const chatKeys = {
all: ['chats'] as const,
status: deploymentKeys.chatStatus,
detail: deploymentKeys.chatDetail,
}
/**
* Auth types for chat access control
*/
export type AuthType = 'public' | 'password' | 'email' | 'sso'
/**
* Form data for creating/updating a chat
*/
export interface ChatFormData {
identifier: string
title: string
description: string
authType: AuthType
password: string
emails: string[]
welcomeMessage: string
selectedOutputBlocks: string[]
}
/**
* Variables for create chat mutation
*/
interface CreateChatVariables {
workflowId: string
formData: ChatFormData
apiKey?: string
imageUrl?: string | null
}
/**
* Variables for update chat mutation
*/
interface UpdateChatVariables {
chatId: string
workflowId: string
formData: ChatFormData
imageUrl?: string | null
}
/**
* Variables for delete chat mutation
*/
interface DeleteChatVariables {
chatId: string
workflowId: string
}
/**
* Response from chat create/update mutations
*/
interface ChatMutationResult {
chatUrl: string
chatId?: string
}
/**
* Parses output block selections into structured output configs
*/
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
return selectedOutputBlocks
.map((outputId) => {
const firstUnderscoreIndex = outputId.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
if (blockId && path) {
return { blockId, path }
}
}
return null
})
.filter((config): config is OutputConfig => config !== null)
}
/**
* Build chat payload from form data
*/
function buildChatPayload(
workflowId: string,
formData: ChatFormData,
apiKey?: string,
imageUrl?: string | null,
isUpdate?: boolean
) {
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
return {
workflowId,
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey,
deployApiEnabled: !isUpdate,
}
}
/**
* Mutation hook for creating a new chat deployment.
* Invalidates chat status and detail queries on success.
*/
export function useCreateChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workflowId,
formData,
apiKey,
imageUrl,
}: CreateChatVariables): Promise<ChatMutationResult> => {
const payload = buildChatPayload(workflowId, formData, apiKey, imageUrl, false)
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || 'Failed to deploy chat')
}
if (!result.chatUrl) {
throw new Error('Response missing chatUrl')
}
logger.info('Chat deployed successfully:', result.chatUrl)
return { chatUrl: result.chatUrl, chatId: result.chatId }
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatStatus(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
},
onError: (error) => {
logger.error('Failed to create chat', { error })
},
})
}
/**
* Mutation hook for updating an existing chat deployment.
* Invalidates chat status and detail queries on success.
*/
export function useUpdateChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
chatId,
workflowId,
formData,
imageUrl,
}: UpdateChatVariables): Promise<ChatMutationResult> => {
const payload = buildChatPayload(workflowId, formData, undefined, imageUrl, true)
const response = await fetch(`/api/chat/manage/${chatId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || 'Failed to update chat')
}
if (!result.chatUrl) {
throw new Error('Response missing chatUrl')
}
logger.info('Chat updated successfully:', result.chatUrl)
return { chatUrl: result.chatUrl, chatId }
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatStatus(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatDetail(variables.chatId),
})
},
onError: (error) => {
logger.error('Failed to update chat', { error })
},
})
}
/**
* Mutation hook for deleting a chat deployment.
* Invalidates chat status and removes chat detail from cache on success.
*/
export function useDeleteChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ chatId }: DeleteChatVariables): Promise<void> => {
const response = await fetch(`/api/chat/manage/${chatId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete chat')
}
logger.info('Chat deleted successfully')
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatStatus(variables.workflowId),
})
queryClient.removeQueries({
queryKey: deploymentKeys.chatDetail(variables.chatId),
})
},
onError: (error) => {
logger.error('Failed to delete chat', { error })
},
})
}

View File

@@ -63,6 +63,32 @@ export function useOrganizations() {
})
}
/**
* Fetch all creator profiles for the current user
*/
async function fetchCreatorProfiles(): Promise<CreatorProfile[]> {
const response = await fetch('/api/creators')
if (!response.ok) {
throw new Error('Failed to fetch creator profiles')
}
const data = await response.json()
return data.profiles || []
}
/**
* Hook to fetch all creator profiles for the current user
*/
export function useCreatorProfiles() {
return useQuery({
queryKey: [...creatorProfileKeys.all, 'list'] as const,
queryFn: fetchCreatorProfiles,
staleTime: 60 * 1000, // 1 minute
placeholderData: keepPreviousData,
})
}
/**
* Fetch creator profile for a user
*/
@@ -155,6 +181,9 @@ export function useSaveCreatorProfile() {
queryClient.invalidateQueries({
queryKey: creatorProfileKeys.profile(variables.referenceId),
})
queryClient.invalidateQueries({
queryKey: [...creatorProfileKeys.all, 'list'],
})
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('creator-profile-saved'))

View File

@@ -0,0 +1,441 @@
import { createLogger } from '@sim/logger'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('DeploymentQueries')
/**
* Query key factory for deployment-related queries
*/
export const deploymentKeys = {
all: ['deployments'] as const,
info: (workflowId: string | null) => [...deploymentKeys.all, 'info', workflowId ?? ''] as const,
versions: (workflowId: string | null) =>
[...deploymentKeys.all, 'versions', workflowId ?? ''] as const,
chatStatus: (workflowId: string | null) =>
[...deploymentKeys.all, 'chatStatus', workflowId ?? ''] as const,
chatDetail: (chatId: string | null) =>
[...deploymentKeys.all, 'chatDetail', chatId ?? ''] as const,
formStatus: (workflowId: string | null) =>
[...deploymentKeys.all, 'formStatus', workflowId ?? ''] as const,
formDetail: (formId: string | null) =>
[...deploymentKeys.all, 'formDetail', formId ?? ''] as const,
}
/**
* Response type from /api/workflows/[id]/deploy GET endpoint
*/
export interface WorkflowDeploymentInfo {
isDeployed: boolean
deployedAt: string | null
apiKey: string | null
needsRedeployment: boolean
}
/**
* Fetches deployment info for a workflow
*/
async function fetchDeploymentInfo(workflowId: string): Promise<WorkflowDeploymentInfo> {
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
if (!response.ok) {
throw new Error('Failed to fetch deployment information')
}
const data = await response.json()
return {
isDeployed: data.isDeployed ?? false,
deployedAt: data.deployedAt ?? null,
apiKey: data.apiKey ?? null,
needsRedeployment: data.needsRedeployment ?? false,
}
}
/**
* Hook to fetch deployment info for a workflow.
* Provides isDeployed status, deployedAt timestamp, apiKey info, and needsRedeployment flag.
*/
export function useDeploymentInfo(workflowId: string | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: deploymentKeys.info(workflowId),
queryFn: () => fetchDeploymentInfo(workflowId!),
enabled: Boolean(workflowId) && (options?.enabled ?? true),
staleTime: 30 * 1000, // 30 seconds
})
}
/**
* Response type from /api/workflows/[id]/deployments GET endpoint
*/
export interface DeploymentVersionsResponse {
versions: WorkflowDeploymentVersionResponse[]
}
/**
* Fetches all deployment versions for a workflow
*/
async function fetchDeploymentVersions(workflowId: string): Promise<DeploymentVersionsResponse> {
const response = await fetch(`/api/workflows/${workflowId}/deployments`)
if (!response.ok) {
throw new Error('Failed to fetch deployment versions')
}
const data = await response.json()
return {
versions: Array.isArray(data.versions) ? data.versions : [],
}
}
/**
* Hook to fetch deployment versions for a workflow.
* Returns a list of all deployment versions with their metadata.
*/
export function useDeploymentVersions(workflowId: string | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: deploymentKeys.versions(workflowId),
queryFn: () => fetchDeploymentVersions(workflowId!),
enabled: Boolean(workflowId) && (options?.enabled ?? true),
staleTime: 30 * 1000, // 30 seconds
})
}
/**
* Response type from /api/workflows/[id]/chat/status GET endpoint
*/
export interface ChatDeploymentStatus {
isDeployed: boolean
deployment: {
id: string
identifier: string
} | null
}
/**
* Fetches chat deployment status for a workflow
*/
async function fetchChatDeploymentStatus(workflowId: string): Promise<ChatDeploymentStatus> {
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (!response.ok) {
throw new Error('Failed to fetch chat deployment status')
}
const data = await response.json()
return {
isDeployed: data.isDeployed ?? false,
deployment: data.deployment ?? null,
}
}
/**
* Hook to fetch chat deployment status for a workflow.
* Returns whether a chat is deployed and basic deployment info.
*/
export function useChatDeploymentStatus(
workflowId: string | null,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: deploymentKeys.chatStatus(workflowId),
queryFn: () => fetchChatDeploymentStatus(workflowId!),
enabled: Boolean(workflowId) && (options?.enabled ?? true),
staleTime: 30 * 1000, // 30 seconds
})
}
/**
* Response type from /api/chat/manage/[id] GET endpoint
*/
export interface ChatDetail {
id: string
identifier: string
title: string
description: string
authType: 'public' | 'password' | 'email' | 'sso'
allowedEmails: string[]
outputConfigs: Array<{ blockId: string; path: string }>
customizations?: {
welcomeMessage?: string
imageUrl?: string
primaryColor?: string
}
isActive: boolean
chatUrl: string
hasPassword: boolean
}
/**
* Fetches chat detail by chat ID
*/
async function fetchChatDetail(chatId: string): Promise<ChatDetail> {
const response = await fetch(`/api/chat/manage/${chatId}`)
if (!response.ok) {
throw new Error('Failed to fetch chat detail')
}
return response.json()
}
/**
* Hook to fetch chat detail by chat ID.
* Returns full chat configuration including customizations and auth settings.
*/
export function useChatDetail(chatId: string | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: deploymentKeys.chatDetail(chatId),
queryFn: () => fetchChatDetail(chatId!),
enabled: Boolean(chatId) && (options?.enabled ?? true),
staleTime: 30 * 1000, // 30 seconds
})
}
/**
* Combined hook to fetch chat deployment info for a workflow.
* First fetches the chat status, then if deployed, fetches the chat detail.
* Returns the combined result.
*/
export function useChatDeploymentInfo(workflowId: string | null, options?: { enabled?: boolean }) {
const statusQuery = useChatDeploymentStatus(workflowId, options)
const chatId = statusQuery.data?.deployment?.id ?? null
const detailQuery = useChatDetail(chatId, {
enabled: Boolean(chatId) && statusQuery.isSuccess && (options?.enabled ?? true),
})
return {
isLoading:
statusQuery.isLoading || Boolean(statusQuery.data?.isDeployed && detailQuery.isLoading),
isError: statusQuery.isError || detailQuery.isError,
error: statusQuery.error ?? detailQuery.error,
chatExists: statusQuery.data?.isDeployed ?? false,
existingChat: detailQuery.data ?? null,
refetch: async () => {
await statusQuery.refetch()
if (statusQuery.data?.deployment?.id) {
await detailQuery.refetch()
}
},
}
}
/**
* Variables for deploy workflow mutation
*/
interface DeployWorkflowVariables {
workflowId: string
deployChatEnabled?: boolean
}
/**
* Response from deploy workflow mutation
*/
interface DeployWorkflowResult {
isDeployed: boolean
deployedAt?: string
apiKey?: string
}
/**
* Mutation hook for deploying a workflow.
* Invalidates deployment info and versions queries on success.
*/
export function useDeployWorkflow() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({
workflowId,
deployChatEnabled = false,
}: DeployWorkflowVariables): Promise<DeployWorkflowResult> => {
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const data = await response.json()
return {
isDeployed: data.isDeployed ?? false,
deployedAt: data.deployedAt,
apiKey: data.apiKey,
}
},
onSuccess: (data, variables) => {
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
setDeploymentStatus(
variables.workflowId,
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined,
data.apiKey
)
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(variables.workflowId, false)
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
},
onError: (error) => {
logger.error('Failed to deploy workflow', { error })
},
})
}
/**
* Variables for undeploy workflow mutation
*/
interface UndeployWorkflowVariables {
workflowId: string
}
/**
* Mutation hook for undeploying a workflow.
* Invalidates deployment info and versions queries on success.
*/
export function useUndeployWorkflow() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({ workflowId }: UndeployWorkflowVariables): Promise<void> => {
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
},
onSuccess: (_, variables) => {
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
setDeploymentStatus(variables.workflowId, false)
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatStatus(variables.workflowId),
})
},
onError: (error) => {
logger.error('Failed to undeploy workflow', { error })
},
})
}
/**
* Variables for activate version mutation
*/
interface ActivateVersionVariables {
workflowId: string
version: number
}
/**
* Response from activate version mutation
*/
interface ActivateVersionResult {
deployedAt?: string
apiKey?: string
}
/**
* Mutation hook for activating (promoting) a specific deployment version.
* Invalidates deployment info and versions queries on success.
*/
export function useActivateDeploymentVersion() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({
workflowId,
version,
}: ActivateVersionVariables): Promise<ActivateVersionResult> => {
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to activate version')
}
return response.json()
},
onMutate: async ({ workflowId, version }) => {
await queryClient.cancelQueries({ queryKey: deploymentKeys.versions(workflowId) })
const previousVersions = queryClient.getQueryData<DeploymentVersionsResponse>(
deploymentKeys.versions(workflowId)
)
if (previousVersions) {
queryClient.setQueryData<DeploymentVersionsResponse>(deploymentKeys.versions(workflowId), {
versions: previousVersions.versions.map((v) => ({
...v,
isActive: v.version === version,
})),
})
}
return { previousVersions }
},
onError: (_, variables, context) => {
logger.error('Failed to activate deployment version')
if (context?.previousVersions) {
queryClient.setQueryData(
deploymentKeys.versions(variables.workflowId),
context.previousVersions
)
}
},
onSuccess: (data, variables) => {
logger.info('Deployment version activated', {
workflowId: variables.workflowId,
version: variables.version,
})
setDeploymentStatus(
variables.workflowId,
true,
data.deployedAt ? new Date(data.deployedAt) : undefined,
data.apiKey
)
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
},
})
}

View File

@@ -0,0 +1,295 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { deploymentKeys } from './deployments'
const logger = createLogger('FormMutations')
/**
* Query keys for form-related queries
*/
export const formKeys = {
all: ['forms'] as const,
status: deploymentKeys.formStatus,
detail: deploymentKeys.formDetail,
}
/**
* Auth types for form access control
*/
export type FormAuthType = 'public' | 'password' | 'email'
/**
* Field configuration for form fields
*/
export interface FieldConfig {
name: string
type: string
label: string
description?: string
required?: boolean
}
/**
* Customizations for form appearance
*/
export interface FormCustomizations {
primaryColor?: string
welcomeMessage?: string
thankYouTitle?: string
thankYouMessage?: string
logoUrl?: string
fieldConfigs?: FieldConfig[]
}
/**
* Existing form data returned from API
*/
export interface ExistingForm {
id: string
identifier: string
title: string
description?: string
customizations: FormCustomizations
authType: FormAuthType
hasPassword?: boolean
allowedEmails?: string[]
showBranding: boolean
isActive: boolean
}
/**
* Form status response from workflow form status API
*/
interface FormStatusResponse {
isDeployed: boolean
form?: {
id: string
}
}
/**
* Fetches form status for a workflow
*/
async function fetchFormStatus(workflowId: string): Promise<FormStatusResponse> {
const response = await fetch(`/api/workflows/${workflowId}/form/status`)
if (!response.ok) {
throw new Error('Failed to fetch form status')
}
return response.json()
}
/**
* Fetches form detail by ID
*/
async function fetchFormDetail(formId: string): Promise<ExistingForm> {
const response = await fetch(`/api/form/manage/${formId}`)
if (!response.ok) {
throw new Error('Failed to fetch form details')
}
const data = await response.json()
return data.form as ExistingForm
}
/**
* Fetches form by workflow - combines status check and detail fetch
*/
async function fetchFormByWorkflow(workflowId: string): Promise<ExistingForm | null> {
const status = await fetchFormStatus(workflowId)
if (!status.isDeployed || !status.form?.id) {
return null
}
return fetchFormDetail(status.form.id)
}
/**
* Hook to fetch form by workflow ID.
* Returns the existing form if deployed, null otherwise.
*/
export function useFormByWorkflow(workflowId: string | null) {
return useQuery({
queryKey: formKeys.status(workflowId),
queryFn: () => fetchFormByWorkflow(workflowId!),
enabled: Boolean(workflowId),
staleTime: 30 * 1000, // 30 seconds
placeholderData: keepPreviousData,
})
}
/**
* Variables for create form mutation
*/
interface CreateFormVariables {
workflowId: string
identifier: string
title: string
description?: string
customizations?: FormCustomizations
authType?: FormAuthType
password?: string
allowedEmails?: string[]
showBranding?: boolean
}
/**
* Variables for update form mutation
*/
interface UpdateFormVariables {
formId: string
workflowId: string
data: {
identifier?: string
title?: string
description?: string
customizations?: FormCustomizations
authType?: FormAuthType
password?: string
allowedEmails?: string[]
showBranding?: boolean
isActive?: boolean
}
}
/**
* Variables for delete form mutation
*/
interface DeleteFormVariables {
formId: string
workflowId: string
}
/**
* Response from form create mutation
*/
interface CreateFormResult {
id: string
formUrl: string
}
/**
* Mutation hook for creating a new form deployment.
* Invalidates form status queries on success.
*/
export function useCreateForm() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (params: CreateFormVariables): Promise<CreateFormResult> => {
const response = await fetch('/api/form', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
// Handle specific error cases
if (data.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(data.error || 'Failed to create form')
}
logger.info('Form created successfully:', { id: data.id })
return {
id: data.id,
formUrl: data.formUrl,
}
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: formKeys.status(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
},
onError: (error) => {
logger.error('Failed to create form', { error })
},
})
}
/**
* Mutation hook for updating an existing form deployment.
* Invalidates form status and detail queries on success.
*/
export function useUpdateForm() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ formId, data }: UpdateFormVariables): Promise<void> => {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!response.ok) {
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || 'Failed to update form')
}
logger.info('Form updated successfully:', { id: formId })
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: formKeys.status(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: formKeys.detail(variables.formId),
})
},
onError: (error) => {
logger.error('Failed to update form', { error })
},
})
}
/**
* Mutation hook for deleting a form deployment.
* Invalidates form status and removes form detail from cache on success.
*/
export function useDeleteForm() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ formId }: DeleteFormVariables): Promise<void> => {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete form')
}
logger.info('Form deleted successfully:', { id: formId })
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: formKeys.status(variables.workflowId),
})
queryClient.removeQueries({
queryKey: formKeys.detail(variables.formId),
})
},
onError: (error) => {
logger.error('Failed to delete form', { error })
},
})
}

View File

@@ -264,3 +264,71 @@ export function useKnowledgeChunksQuery(
placeholderData: keepPreviousData,
})
}
export interface DocumentChunkSearchParams {
knowledgeBaseId: string
documentId: string
search: string
}
/**
* Fetches all chunks matching a search query by paginating through results.
* This is used for search functionality where we need all matching chunks.
*/
export async function fetchAllDocumentChunks({
knowledgeBaseId,
documentId,
search,
}: DocumentChunkSearchParams): Promise<ChunkData[]> {
const allResults: ChunkData[] = []
let hasMore = true
let offset = 0
const limit = 100
while (hasMore) {
const response = await fetchKnowledgeChunks({
knowledgeBaseId,
documentId,
search,
limit,
offset,
})
allResults.push(...response.chunks)
hasMore = response.pagination.hasMore
offset += limit
}
return allResults
}
export const serializeSearchParams = (params: DocumentChunkSearchParams) =>
JSON.stringify({
search: params.search,
})
/**
* Hook to search for chunks in a document.
* Fetches all matching chunks and returns them for client-side pagination.
*/
export function useDocumentChunkSearchQuery(
params: DocumentChunkSearchParams,
options?: {
enabled?: boolean
}
) {
const searchKey = serializeSearchParams(params)
return useQuery({
queryKey: [
...knowledgeKeys.document(params.knowledgeBaseId, params.documentId),
'search',
searchKey,
],
queryFn: () => fetchAllDocumentChunks(params),
enabled:
(options?.enabled ?? true) &&
Boolean(params.knowledgeBaseId && params.documentId && params.search.trim()),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import { deploymentKeys } from '@/hooks/queries/deployments'
const logger = createLogger('ScheduleQueries')
@@ -176,6 +177,13 @@ export function useRedeployWorkflowSchedule() {
queryClient.invalidateQueries({
queryKey: scheduleKeys.schedule(workflowId, blockId),
})
// Also invalidate deployment queries since we redeployed
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(workflowId),
})
},
onError: (error) => {
logger.error('Failed to redeploy workflow', { error })

View File

@@ -34,6 +34,7 @@ export interface WorkflowMcpServer {
createdBy: string
name: string
description: string | null
isPublic: boolean
createdAt: string
updatedAt: string
toolCount?: number
@@ -166,17 +167,25 @@ interface CreateWorkflowMcpServerParams {
workspaceId: string
name: string
description?: string
isPublic?: boolean
workflowIds?: string[]
}
export function useCreateWorkflowMcpServer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, name, description }: CreateWorkflowMcpServerParams) => {
mutationFn: async ({
workspaceId,
name,
description,
isPublic,
workflowIds,
}: CreateWorkflowMcpServerParams) => {
const response = await fetch('/api/mcp/workflow-servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, name, description }),
body: JSON.stringify({ workspaceId, name, description, isPublic, workflowIds }),
})
const data = await response.json()
@@ -204,6 +213,7 @@ interface UpdateWorkflowMcpServerParams {
serverId: string
name?: string
description?: string
isPublic?: boolean
}
export function useUpdateWorkflowMcpServer() {
@@ -215,13 +225,14 @@ export function useUpdateWorkflowMcpServer() {
serverId,
name,
description,
isPublic,
}: UpdateWorkflowMcpServerParams) => {
const response = await fetch(
`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
body: JSON.stringify({ name, description, isPublic }),
}
)

View File

@@ -4,6 +4,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
createOptimisticMutationHandlers,
generateTempId,
@@ -635,6 +636,13 @@ export function useDeployChildWorkflow() {
queryClient.invalidateQueries({
queryKey: workflowKeys.deploymentStatus(variables.workflowId),
})
// Also invalidate deployment queries
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
},
onError: (error) => {
logger.error('Failed to deploy child workflow', { error })

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import type { CopilotMode, CopilotModelId, CopilotTransportMode } from '@/lib/copilot/models'
const logger = createLogger('CopilotAPI')
@@ -27,8 +28,8 @@ export interface CopilotMessage {
* Chat config stored in database
*/
export interface CopilotChatConfig {
mode?: 'ask' | 'build' | 'plan'
model?: string
mode?: CopilotMode
model?: CopilotModelId
}
/**
@@ -65,30 +66,8 @@ export interface SendMessageRequest {
userMessageId?: string // ID from frontend for the user message
chatId?: string
workflowId?: string
mode?: 'ask' | 'agent' | 'plan'
model?:
| 'gpt-5-fast'
| 'gpt-5'
| 'gpt-5-medium'
| 'gpt-5-high'
| 'gpt-5.1-fast'
| 'gpt-5.1'
| 'gpt-5.1-medium'
| 'gpt-5.1-high'
| 'gpt-5-codex'
| 'gpt-5.1-codex'
| 'gpt-5.2'
| 'gpt-5.2-codex'
| 'gpt-5.2-pro'
| 'gpt-4o'
| 'gpt-4.1'
| 'o3'
| 'claude-4-sonnet'
| 'claude-4.5-haiku'
| 'claude-4.5-sonnet'
| 'claude-4.5-opus'
| 'claude-4.1-opus'
| 'gemini-3-pro'
mode?: CopilotMode | CopilotTransportMode
model?: CopilotModelId
prefetch?: boolean
createNewChat?: boolean
stream?: boolean

View File

@@ -0,0 +1,35 @@
export const COPILOT_MODEL_IDS = [
'gpt-5-fast',
'gpt-5',
'gpt-5-medium',
'gpt-5-high',
'gpt-5.1-fast',
'gpt-5.1',
'gpt-5.1-medium',
'gpt-5.1-high',
'gpt-5-codex',
'gpt-5.1-codex',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.2-pro',
'gpt-4o',
'gpt-4.1',
'o3',
'claude-4-sonnet',
'claude-4.5-haiku',
'claude-4.5-sonnet',
'claude-4.5-opus',
'claude-4.1-opus',
'gemini-3-pro',
] as const
export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number]
export const COPILOT_MODES = ['ask', 'build', 'plan'] as const
export type CopilotMode = (typeof COPILOT_MODES)[number]
export const COPILOT_TRANSPORT_MODES = ['ask', 'agent', 'plan'] as const
export type CopilotTransportMode = (typeof COPILOT_TRANSPORT_MODES)[number]
export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const
export type CopilotRequestMode = (typeof COPILOT_REQUEST_MODES)[number]

View File

@@ -25,36 +25,41 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved block options', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
[ClientToolCallState.generating]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved block operations', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to get block operations', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block operations', icon: XCircle },
[ClientToolCallState.rejected]: {
text: 'Skipped getting block options',
text: 'Skipped getting block operations',
icon: MinusCircle,
},
},
getDynamicText: (params, state) => {
if (params?.blockId && typeof params.blockId === 'string') {
const blockId =
(params as any)?.blockId ||
(params as any)?.blockType ||
(params as any)?.block_id ||
(params as any)?.block_type
if (typeof blockId === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(params.blockId)
const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase()
const blockConfig = getBlock(blockId)
const blockName = (blockConfig?.name ?? blockId.replace(/_/g, ' ')).toLowerCase()
switch (state) {
case ClientToolCallState.success:
return `Retrieved ${blockName} options`
return `Retrieved ${blockName} operations`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Retrieving ${blockName} options`
return `Retrieving ${blockName} operations`
case ClientToolCallState.error:
return `Failed to retrieve ${blockName} options`
return `Failed to retrieve ${blockName} operations`
case ClientToolCallState.aborted:
return `Aborted retrieving ${blockName} options`
return `Aborted retrieving ${blockName} operations`
case ClientToolCallState.rejected:
return `Skipped retrieving ${blockName} options`
return `Skipped retrieving ${blockName} operations`
}
}
return undefined

View File

@@ -28,6 +28,7 @@ import './workflow/deploy-api'
import './workflow/deploy-chat'
import './workflow/deploy-mcp'
import './workflow/edit-workflow'
import './workflow/redeploy'
import './workflow/run-workflow'
import './workflow/set-global-workflow-variables'

View File

@@ -15,6 +15,8 @@ interface ApiDeploymentDetails {
isDeployed: boolean
deployedAt: string | null
endpoint: string | null
apiKey: string | null
needsRedeployment: boolean
}
interface ChatDeploymentDetails {
@@ -22,6 +24,14 @@ interface ChatDeploymentDetails {
chatId: string | null
identifier: string | null
chatUrl: string | null
title: string | null
description: string | null
authType: string | null
allowedEmails: string[] | null
outputConfigs: Array<{ blockId: string; path: string }> | null
welcomeMessage: string | null
primaryColor: string | null
hasPassword: boolean
}
interface McpDeploymentDetails {
@@ -31,6 +41,8 @@ interface McpDeploymentDetails {
serverName: string
toolName: string
toolDescription: string | null
parameterSchema?: Record<string, unknown> | null
toolId?: string | null
}>
}
@@ -96,6 +108,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
isDeployed: isApiDeployed,
deployedAt: apiDeploy?.deployedAt || null,
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
apiKey: apiDeploy?.apiKey || null,
needsRedeployment: apiDeploy?.needsRedeployment === true,
}
// Chat deployment details
@@ -105,6 +119,18 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
chatId: chatDeploy?.deployment?.id || null,
identifier: chatDeploy?.deployment?.identifier || null,
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
title: chatDeploy?.deployment?.title || null,
description: chatDeploy?.deployment?.description || null,
authType: chatDeploy?.deployment?.authType || null,
allowedEmails: Array.isArray(chatDeploy?.deployment?.allowedEmails)
? chatDeploy?.deployment?.allowedEmails
: null,
outputConfigs: Array.isArray(chatDeploy?.deployment?.outputConfigs)
? chatDeploy?.deployment?.outputConfigs
: null,
welcomeMessage: chatDeploy?.deployment?.customizations?.welcomeMessage || null,
primaryColor: chatDeploy?.deployment?.customizations?.primaryColor || null,
hasPassword: chatDeploy?.deployment?.hasPassword === true,
}
// MCP deployment details - find servers that have this workflow as a tool
@@ -129,6 +155,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
serverName: server.name,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
parameterSchema: tool.parameterSchema ?? null,
toolId: tool.id ?? null,
})
}
}

View File

@@ -208,54 +208,70 @@ export class DeployChatClientTool extends BaseClientTool {
return
}
// Deploy action - validate required fields
if (!args?.identifier && !workflow?.name) {
throw new Error('Either identifier or workflow name is required')
this.setState(ClientToolCallState.executing)
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
const statusJson = statusRes.ok ? await statusRes.json() : null
const existingDeployment = statusJson?.deployment || null
const baseIdentifier =
existingDeployment?.identifier || this.generateIdentifier(workflow?.name || 'chat')
const baseTitle = existingDeployment?.title || workflow?.name || 'Chat'
const baseDescription = existingDeployment?.description || ''
const baseAuthType = existingDeployment?.authType || 'public'
const baseWelcomeMessage =
existingDeployment?.customizations?.welcomeMessage || 'Hi there! How can I help you today?'
const basePrimaryColor =
existingDeployment?.customizations?.primaryColor || 'var(--brand-primary-hover-hex)'
const baseAllowedEmails = Array.isArray(existingDeployment?.allowedEmails)
? existingDeployment.allowedEmails
: []
const baseOutputConfigs = Array.isArray(existingDeployment?.outputConfigs)
? existingDeployment.outputConfigs
: []
const identifier = args?.identifier || baseIdentifier
const title = args?.title || baseTitle
const description = args?.description ?? baseDescription
const authType = args?.authType || baseAuthType
const welcomeMessage = args?.welcomeMessage || baseWelcomeMessage
const outputConfigs = args?.outputConfigs || baseOutputConfigs
const allowedEmails = args?.allowedEmails || baseAllowedEmails
const primaryColor = basePrimaryColor
if (!identifier || !title) {
throw new Error('Chat identifier and title are required')
}
if (!args?.title && !workflow?.name) {
throw new Error('Chat title is required')
}
const identifier = args?.identifier || this.generateIdentifier(workflow?.name || 'chat')
const title = args?.title || workflow?.name || 'Chat'
const description = args?.description || ''
const authType = args?.authType || 'public'
const welcomeMessage = args?.welcomeMessage || 'Hi there! How can I help you today?'
// Validate auth-specific requirements
if (authType === 'password' && !args?.password) {
if (authType === 'password' && !args?.password && !existingDeployment?.hasPassword) {
throw new Error('Password is required when using password protection')
}
if (
(authType === 'email' || authType === 'sso') &&
(!args?.allowedEmails || args.allowedEmails.length === 0)
) {
if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) {
throw new Error(`At least one email or domain is required when using ${authType} access`)
}
this.setState(ClientToolCallState.executing)
const outputConfigs = args?.outputConfigs || []
const payload = {
workflowId,
identifier: identifier.trim(),
title: title.trim(),
description: description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
primaryColor,
welcomeMessage: welcomeMessage.trim(),
},
authType,
password: authType === 'password' ? args?.password : undefined,
allowedEmails: authType === 'email' || authType === 'sso' ? args?.allowedEmails : [],
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
}
const res = await fetch('/api/chat', {
method: 'POST',
const isUpdating = Boolean(existingDeployment?.id)
const endpoint = isUpdating ? `/api/chat/manage/${existingDeployment.id}` : '/api/chat'
const method = isUpdating ? 'PATCH' : 'POST'
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})

View File

@@ -128,7 +128,6 @@ export class DeployMcpClientTool extends BaseClientTool {
this.setState(ClientToolCallState.executing)
// Build parameter schema with descriptions if provided
let parameterSchema: Record<string, unknown> | undefined
if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) {
const properties: Record<string, { description: string }> = {}
@@ -155,9 +154,49 @@ export class DeployMcpClientTool extends BaseClientTool {
const data = await res.json()
if (!res.ok) {
// Handle specific error cases
if (data.error?.includes('already added')) {
throw new Error('This workflow is already deployed to this MCP server')
const toolsRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`
)
const toolsJson = toolsRes.ok ? await toolsRes.json() : null
const tools = toolsJson?.data?.tools || []
const existingTool = tools.find((tool: any) => tool.workflowId === workflowId)
if (!existingTool?.id) {
throw new Error('This workflow is already deployed to this MCP server')
}
const patchRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools/${existingTool.id}?workspaceId=${workspaceId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: args.toolName?.trim(),
toolDescription: args.toolDescription?.trim(),
parameterSchema,
}),
}
)
const patchJson = patchRes.ok ? await patchRes.json() : null
if (!patchRes.ok) {
const patchError = patchJson?.error || `Failed to update MCP tool (${patchRes.status})`
throw new Error(patchError)
}
const updatedTool = patchJson?.data?.tool
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow MCP tool updated to "${updatedTool?.toolName || existingTool.toolName}".`,
{
success: true,
toolId: updatedTool?.id || existingTool.id,
toolName: updatedTool?.toolName || existingTool.toolName,
toolDescription: updatedTool?.toolDescription || existingTool.toolDescription,
serverId: args.serverId,
updated: true,
}
)
logger.info('Updated workflow MCP tool', { toolId: existingTool.id })
return
}
if (data.error?.includes('not deployed')) {
throw new Error('Workflow must be deployed before adding as an MCP tool')

View File

@@ -38,6 +38,18 @@ export class EditWorkflowClientTool extends BaseClientTool {
super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata)
}
async markToolComplete(status: number, message?: any, data?: any): Promise<boolean> {
const logger = createLogger('EditWorkflowClientTool')
logger.info('markToolComplete payload', {
toolCallId: this.toolCallId,
toolName: this.name,
status,
message,
data,
})
return super.markToolComplete(status, message, data)
}
/**
* Get sanitized workflow JSON from a workflow state, merge subblocks, and sanitize for copilot
* This matches what get_user_workflow returns
@@ -173,21 +185,13 @@ export class EditWorkflowClientTool extends BaseClientTool {
async execute(args?: EditWorkflowArgs): Promise<void> {
const logger = createLogger('EditWorkflowClientTool')
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
// Use timeout protection to ensure tool always completes
await this.executeWithTimeout(async () => {
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
// Even if skipped, ensure we mark complete with current workflow state
if (!this.hasBeenMarkedComplete()) {
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
await this.markToolComplete(
200,
'Tool already executed',
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
)
}
return
}
this.hasExecuted = true
logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args })
this.setState(ClientToolCallState.executing)

View File

@@ -0,0 +1,71 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export class RedeployClientTool extends BaseClientTool {
static readonly id = 'redeploy'
private hasExecuted = false
constructor(toolCallId: string) {
super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Redeploying workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Redeploy workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Redeploying workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Redeployed workflow', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to redeploy workflow', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted redeploy', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped redeploy', icon: XCircle },
},
interrupt: undefined,
}
async execute(): Promise<void> {
const logger = createLogger('RedeployClientTool')
try {
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
this.hasExecuted = true
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
throw new Error('No workflow ID provided')
}
const res = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deployChatEnabled: false }),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
const errorText = json?.error || `Server error (${res.status})`
throw new Error(errorText)
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Workflow redeployed', {
workflowId: activeWorkflowId,
deployedAt: json?.deployedAt || null,
schedule: json?.schedule,
})
} catch (error: any) {
logger.error('Redeploy failed', { message: error?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, error?.message || 'Failed to redeploy workflow')
}
}
}

View File

@@ -627,12 +627,9 @@ function createBlockFromParams(
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 = []
}
// Normalize array subblocks with id fields (inputFormat, table rows, etc.)
if (shouldNormalizeArrayIds(key)) {
sanitizedValue = normalizeArrayWithIds(value)
}
// Special handling for tools - normalize and filter disallowed
@@ -720,6 +717,55 @@ function normalizeTools(tools: any[]): any[] {
})
}
/** UUID v4 regex pattern for validation */
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Subblock types that store arrays of objects with `id` fields.
* The LLM may generate arbitrary IDs which need to be converted to proper UUIDs.
*/
const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([
'inputFormat', // input-format: Fields with id, name, type, value, collapsed
'headers', // table: Rows with id, cells (used for HTTP headers)
'params', // table: Rows with id, cells (used for query params)
'variables', // table or variables-input: Rows/assignments with id
'tagFilters', // knowledge-tag-filters: Filters with id, tagName, etc.
'documentTags', // document-tag-entry: Tags with id, tagName, etc.
'metrics', // eval-input: Metrics with id, name, description, range
])
/**
* Normalizes array subblock values by ensuring each item has a valid UUID.
* The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need
* to be converted to proper UUIDs for consistency with UI-created items.
*/
function normalizeArrayWithIds(value: unknown): any[] {
if (!Array.isArray(value)) {
return []
}
return value.map((item: any) => {
if (!item || typeof item !== 'object') {
return item
}
// Check if id is missing or not a valid UUID
const hasValidUUID = typeof item.id === 'string' && UUID_REGEX.test(item.id)
if (!hasValidUUID) {
return { ...item, id: crypto.randomUUID() }
}
return item
})
}
/**
* Checks if a subblock key should have its array items normalized with UUIDs.
*/
function shouldNormalizeArrayIds(key: string): boolean {
return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key)
}
/**
* Normalize responseFormat to ensure consistent storage
* Handles both string (JSON) and object formats
@@ -832,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 }
@@ -917,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')
*/
@@ -1360,12 +1504,9 @@ function applyOperationsToWorkflowState(
}
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 = []
}
// Normalize array subblocks with id fields (inputFormat, table rows, etc.)
if (shouldNormalizeArrayIds(key)) {
sanitizedValue = normalizeArrayWithIds(value)
}
// Special handling for tools - normalize and filter disallowed
@@ -2011,10 +2152,9 @@ function applyOperationsToWorkflowState(
let sanitizedValue = value
if (key === 'inputFormat' && value !== null && value !== undefined) {
if (!Array.isArray(value)) {
sanitizedValue = []
}
// Normalize array subblocks with id fields (inputFormat, table rows, etc.)
if (shouldNormalizeArrayIds(key)) {
sanitizedValue = normalizeArrayWithIds(value)
}
// Special handling for tools - normalize and filter disallowed

View File

@@ -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>

View File

@@ -0,0 +1,18 @@
import { createLogger } from '@sim/logger'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
const logger = createLogger('TriggerUtils')
/**
* Check if a workflow has a valid start block by loading from database
*/
export async function hasValidStartBlock(workflowId: string): Promise<boolean> {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
return hasValidStartBlockInState(normalizedData)
} catch (error) {
logger.warn('Error checking for start block:', error)
return false
}
}

View File

@@ -90,7 +90,6 @@ function generateMockValue(type: string, _description?: string, fieldName?: stri
* Recursively processes nested output structures
*/
function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown {
// Prevent infinite recursion
if (depth > maxDepth) {
return null
}

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { type CopilotChat, sendStreamingMessage } from '@/lib/copilot/api'
import type { CopilotTransportMode } from '@/lib/copilot/models'
import type {
BaseClientToolMetadata,
ClientToolDisplay,
@@ -71,6 +72,7 @@ import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow
import { ListWorkspaceMcpServersClientTool } from '@/lib/copilot/tools/client/workflow/list-workspace-mcp-servers'
import { ManageCustomToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-custom-tool'
import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-mcp-tool'
import { RedeployClientTool } from '@/lib/copilot/tools/client/workflow/redeploy'
import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow'
import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables'
import { getQueryClient } from '@/app/_shell/providers/query-provider'
@@ -84,7 +86,9 @@ import type {
} from '@/stores/panel/copilot/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('CopilotStore')
@@ -147,6 +151,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
deploy_api: (id) => new DeployApiClientTool(id),
deploy_chat: (id) => new DeployChatClientTool(id),
deploy_mcp: (id) => new DeployMcpClientTool(id),
redeploy: (id) => new RedeployClientTool(id),
list_workspace_mcp_servers: (id) => new ListWorkspaceMcpServersClientTool(id),
create_workspace_mcp_server: (id) => new CreateWorkspaceMcpServerClientTool(id),
check_deployment_status: (id) => new CheckDeploymentStatusClientTool(id),
@@ -209,6 +214,7 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
deploy_api: (DeployApiClientTool as any)?.metadata,
deploy_chat: (DeployChatClientTool as any)?.metadata,
deploy_mcp: (DeployMcpClientTool as any)?.metadata,
redeploy: (RedeployClientTool as any)?.metadata,
list_workspace_mcp_servers: (ListWorkspaceMcpServersClientTool as any)?.metadata,
create_workspace_mcp_server: (CreateWorkspaceMcpServerClientTool as any)?.metadata,
check_deployment_status: (CheckDeploymentStatusClientTool as any)?.metadata,
@@ -237,6 +243,7 @@ const TEXT_BLOCK_TYPE = 'text'
const THINKING_BLOCK_TYPE = 'thinking'
const DATA_PREFIX = 'data: '
const DATA_PREFIX_LENGTH = 6
const CONTINUE_OPTIONS_TAG = '<options>{"1":"Continue"}</options>'
// Resolve display text/icon for a tool based on its state
function resolveToolDisplay(
@@ -360,6 +367,7 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
const { toolCallsById, messages } = get()
const updatedMap = { ...toolCallsById }
const abortedIds = new Set<string>()
let hasUpdates = false
for (const [id, tc] of Object.entries(toolCallsById)) {
const st = tc.state as any
// Abort anything not already terminal success/error/rejected/aborted
@@ -373,11 +381,19 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
updatedMap[id] = {
...tc,
state: ClientToolCallState.aborted,
subAgentStreaming: false,
display: resolveToolDisplay(tc.name, ClientToolCallState.aborted, id, (tc as any).params),
}
hasUpdates = true
} else if (tc.subAgentStreaming) {
updatedMap[id] = {
...tc,
subAgentStreaming: false,
}
hasUpdates = true
}
}
if (abortedIds.size > 0) {
if (abortedIds.size > 0 || hasUpdates) {
set({ toolCallsById: updatedMap })
// Update inline blocks in-place for the latest assistant message only (most relevant)
set((s: CopilotStore) => {
@@ -620,6 +636,97 @@ function createErrorMessage(
}
}
/**
* Builds a workflow snapshot suitable for checkpoint persistence.
*/
function buildCheckpointWorkflowState(workflowId: string): WorkflowState | null {
const rawState = useWorkflowStore.getState().getWorkflowState()
if (!rawState) return null
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, workflowId)
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
(acc, [blockId, block]) => {
if (block?.type && block?.name) {
acc[blockId] = {
...block,
id: block.id || blockId,
enabled: block.enabled !== undefined ? block.enabled : true,
horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true,
height: block.height !== undefined ? block.height : 90,
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
data: block.data || {},
position: block.position || { x: 0, y: 0 },
}
}
return acc
},
{} as WorkflowState['blocks']
)
return {
blocks: filteredBlocks,
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
lastSaved: rawState.lastSaved || Date.now(),
deploymentStatuses: rawState.deploymentStatuses || {},
}
}
/**
* Persists a previously captured snapshot as a workflow checkpoint.
*/
async function saveMessageCheckpoint(
messageId: string,
get: () => CopilotStore,
set: (partial: Partial<CopilotStore> | ((state: CopilotStore) => Partial<CopilotStore>)) => void
): Promise<boolean> {
const { workflowId, currentChat, messageSnapshots, messageCheckpoints } = get()
if (!workflowId || !currentChat?.id) return false
const snapshot = messageSnapshots[messageId]
if (!snapshot) return false
const nextSnapshots = { ...messageSnapshots }
delete nextSnapshots[messageId]
set({ messageSnapshots: nextSnapshots })
try {
const response = await fetch('/api/copilot/checkpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
chatId: currentChat.id,
messageId,
workflowState: JSON.stringify(snapshot),
}),
})
if (!response.ok) {
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
}
const result = await response.json()
const newCheckpoint = result.checkpoint
if (newCheckpoint) {
const existingCheckpoints = messageCheckpoints[messageId] || []
const updatedCheckpoints = {
...messageCheckpoints,
[messageId]: [newCheckpoint, ...existingCheckpoints],
}
set({ messageCheckpoints: updatedCheckpoints })
}
return true
} catch (error) {
logger.error('Failed to create checkpoint from snapshot:', error)
return false
}
}
function stripTodoTags(text: string): string {
if (!text) return text
return text
@@ -826,6 +933,8 @@ interface StreamingContext {
newChatId?: string
doneEventCount: number
streamComplete?: boolean
wasAborted?: boolean
suppressContinueOption?: boolean
/** Track active subagent sessions by parent tool call ID */
subAgentParentToolCallId?: string
/** Track subagent content per parent tool call */
@@ -843,6 +952,132 @@ type SSEHandler = (
set: any
) => Promise<void> | void
function appendTextBlock(context: StreamingContext, text: string) {
if (!text) return
context.accumulatedContent.append(text)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += text
return
}
}
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = text
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
function appendContinueOption(content: string): string {
if (/<options>/i.test(content)) return content
const suffix = content.trim().length > 0 ? '\n\n' : ''
return `${content}${suffix}${CONTINUE_OPTIONS_TAG}`
}
function appendContinueOptionBlock(blocks: any[]): any[] {
if (!Array.isArray(blocks)) return blocks
const hasOptions = blocks.some(
(block) =>
block?.type === TEXT_BLOCK_TYPE &&
typeof block.content === 'string' &&
/<options>/i.test(block.content)
)
if (hasOptions) return blocks
return [
...blocks,
{
type: TEXT_BLOCK_TYPE,
content: CONTINUE_OPTIONS_TAG,
timestamp: Date.now(),
},
]
}
function beginThinkingBlock(context: StreamingContext) {
if (!context.currentThinkingBlock) {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = ''
context.currentThinkingBlock.timestamp = Date.now()
;(context.currentThinkingBlock as any).startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
}
/**
* Removes thinking tags (raw or escaped) from streamed content.
*/
function stripThinkingTags(text: string): string {
return text.replace(/<\/?thinking[^>]*>/gi, '').replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
}
function appendThinkingContent(context: StreamingContext, text: string) {
if (!text) return
const cleanedText = stripThinkingTags(text)
if (!cleanedText) return
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += cleanedText
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = cleanedText
context.currentThinkingBlock.timestamp = Date.now()
context.currentThinkingBlock.startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
}
function finalizeThinkingBlock(context: StreamingContext) {
if (context.currentThinkingBlock) {
context.currentThinkingBlock.duration =
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
}
context.isInThinkingBlock = false
context.currentThinkingBlock = null
context.currentTextBlock = null
}
function upsertToolCallBlock(context: StreamingContext, toolCall: CopilotToolCall) {
let found = false
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === toolCall.id) {
context.contentBlocks[i] = { ...b, toolCall }
found = true
break
}
}
if (!found) {
context.contentBlocks.push({ type: 'tool_call', toolCall, timestamp: Date.now() })
}
}
function appendSubAgentText(context: StreamingContext, parentToolCallId: string, text: string) {
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
context.subAgentContent[parentToolCallId] += text
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + text
} else {
blocks.push({
type: 'subagent_text',
content: text,
timestamp: Date.now(),
})
}
}
const sseHandlers: Record<string, SSEHandler> = {
chat_id: async (data, context, get) => {
context.newChatId = data.chatId
@@ -1033,17 +1268,7 @@ const sseHandlers: Record<string, SSEHandler> = {
logger.info('[toolCallsById] map updated', updated)
// Add/refresh inline content block
let found = false
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === toolCallId) {
context.contentBlocks[i] = { ...b, toolCall: tc }
found = true
break
}
}
if (!found)
context.contentBlocks.push({ type: 'tool_call', toolCall: tc, timestamp: Date.now() })
upsertToolCallBlock(context, tc)
updateStreamingMessage(set, context)
}
},
@@ -1079,20 +1304,14 @@ const sseHandlers: Record<string, SSEHandler> = {
logger.info('[toolCallsById] → pending', { id, name, params: args })
// Ensure an inline content block exists/updated for this tool call
let found = false
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === id) {
context.contentBlocks[i] = { ...b, toolCall: next }
found = true
break
}
}
if (!found) {
context.contentBlocks.push({ type: 'tool_call', toolCall: next, timestamp: Date.now() })
}
upsertToolCallBlock(context, next)
updateStreamingMessage(set, context)
// Do not execute on partial tool_call frames
if (isPartial) {
return
}
// Prefer interface-based registry to determine interrupt and execute
try {
const def = name ? getTool(name) : undefined
@@ -1275,44 +1494,18 @@ const sseHandlers: Record<string, SSEHandler> = {
reasoning: (data, context, _get, set) => {
const phase = (data && (data.phase || data?.data?.phase)) as string | undefined
if (phase === 'start') {
if (!context.currentThinkingBlock) {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = ''
context.currentThinkingBlock.timestamp = Date.now()
;(context.currentThinkingBlock as any).startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
beginThinkingBlock(context)
updateStreamingMessage(set, context)
return
}
if (phase === 'end') {
if (context.currentThinkingBlock) {
;(context.currentThinkingBlock as any).duration =
Date.now() - ((context.currentThinkingBlock as any).startTime || Date.now())
}
context.isInThinkingBlock = false
context.currentThinkingBlock = null
context.currentTextBlock = null
finalizeThinkingBlock(context)
updateStreamingMessage(set, context)
return
}
const chunk: string = typeof data?.data === 'string' ? data.data : data?.content || ''
if (!chunk) return
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += chunk
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = chunk
context.currentThinkingBlock.timestamp = Date.now()
;(context.currentThinkingBlock as any).startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
appendThinkingContent(context, chunk)
updateStreamingMessage(set, context)
},
content: (data, context, get, set) => {
@@ -1327,21 +1520,23 @@ const sseHandlers: Record<string, SSEHandler> = {
const designWorkflowStartRegex = /<design_workflow>/
const designWorkflowEndRegex = /<\/design_workflow>/
const appendTextToContent = (text: string) => {
if (!text) return
context.accumulatedContent.append(text)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += text
return
}
const splitTrailingPartialTag = (
text: string,
tags: string[]
): { text: string; remaining: string } => {
const partialIndex = text.lastIndexOf('<')
if (partialIndex < 0) {
return { text, remaining: '' }
}
const possibleTag = text.substring(partialIndex)
const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag))
if (!matchesTagStart) {
return { text, remaining: '' }
}
return {
text: text.substring(0, partialIndex),
remaining: possibleTag,
}
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = text
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
while (contentToProcess.length > 0) {
@@ -1363,13 +1558,19 @@ const sseHandlers: Record<string, SSEHandler> = {
hasProcessedContent = true
} else {
// Still in design_workflow block, accumulate content
context.designWorkflowContent += contentToProcess
const { text, remaining } = splitTrailingPartialTag(contentToProcess, [
'</design_workflow>',
])
context.designWorkflowContent += text
// Update store with partial content for streaming effect (available in all modes)
set({ streamingPlanContent: context.designWorkflowContent })
contentToProcess = ''
contentToProcess = remaining
hasProcessedContent = true
if (remaining) {
break
}
}
continue
}
@@ -1380,7 +1581,7 @@ const sseHandlers: Record<string, SSEHandler> = {
if (designStartMatch) {
const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index)
if (textBeforeDesign) {
appendTextToContent(textBeforeDesign)
appendTextBlock(context, textBeforeDesign)
hasProcessedContent = true
}
context.isInDesignWorkflowBlock = true
@@ -1471,63 +1672,27 @@ const sseHandlers: Record<string, SSEHandler> = {
const endMatch = thinkingEndRegex.exec(contentToProcess)
if (endMatch) {
const thinkingContent = contentToProcess.substring(0, endMatch.index)
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += thinkingContent
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = thinkingContent
context.currentThinkingBlock.timestamp = Date.now()
context.currentThinkingBlock.startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = false
if (context.currentThinkingBlock) {
context.currentThinkingBlock.duration =
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
}
context.currentThinkingBlock = null
context.currentTextBlock = null
appendThinkingContent(context, thinkingContent)
finalizeThinkingBlock(context)
contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length)
hasProcessedContent = true
} else {
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += contentToProcess
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = contentToProcess
context.currentThinkingBlock.timestamp = Date.now()
context.currentThinkingBlock.startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['</thinking>'])
if (text) {
appendThinkingContent(context, text)
hasProcessedContent = true
}
contentToProcess = remaining
if (remaining) {
break
}
contentToProcess = ''
hasProcessedContent = true
}
} else {
const startMatch = thinkingStartRegex.exec(contentToProcess)
if (startMatch) {
const textBeforeThinking = contentToProcess.substring(0, startMatch.index)
if (textBeforeThinking) {
context.accumulatedContent.append(textBeforeThinking)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += textBeforeThinking
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textBeforeThinking
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textBeforeThinking
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
appendTextBlock(context, textBeforeThinking)
hasProcessedContent = true
}
context.isInThinkingBlock = true
@@ -1556,25 +1721,7 @@ const sseHandlers: Record<string, SSEHandler> = {
remaining = contentToProcess.substring(partialTagIndex)
}
if (textToAdd) {
context.accumulatedContent.append(textToAdd)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += textToAdd
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textToAdd
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textToAdd
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
appendTextBlock(context, textToAdd)
hasProcessedContent = true
}
contentToProcess = remaining
@@ -1612,37 +1759,13 @@ const sseHandlers: Record<string, SSEHandler> = {
stream_end: (_data, context, _get, set) => {
if (context.pendingContent) {
if (context.isInThinkingBlock && context.currentThinkingBlock) {
context.currentThinkingBlock.content += context.pendingContent
appendThinkingContent(context, context.pendingContent)
} else if (context.pendingContent.trim()) {
context.accumulatedContent.append(context.pendingContent)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += context.pendingContent
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = context.pendingContent
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = context.pendingContent
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
appendTextBlock(context, context.pendingContent)
}
context.pendingContent = ''
}
if (context.currentThinkingBlock) {
context.currentThinkingBlock.duration =
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
}
context.isInThinkingBlock = false
context.currentThinkingBlock = null
context.currentTextBlock = null
finalizeThinkingBlock(context)
updateStreamingMessage(set, context)
},
default: () => {},
@@ -1740,29 +1863,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
return
}
// Initialize if needed
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
// Append content
context.subAgentContent[parentToolCallId] += data.data
// Update or create the last text block in subAgentBlocks
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + data.data
} else {
blocks.push({
type: 'subagent_text',
content: data.data,
timestamp: Date.now(),
})
}
appendSubAgentText(context, parentToolCallId, data.data)
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
@@ -1773,34 +1874,13 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
const phase = data?.phase || data?.data?.phase
if (!parentToolCallId) return
// Initialize if needed
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
// For reasoning, we just append the content (treating start/end as markers)
if (phase === 'start' || phase === 'end') return
const chunk = typeof data?.data === 'string' ? data.data : data?.content || ''
if (!chunk) return
context.subAgentContent[parentToolCallId] += chunk
// Update or create the last text block in subAgentBlocks
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + chunk
} else {
blocks.push({
type: 'subagent_text',
content: chunk,
timestamp: Date.now(),
})
}
appendSubAgentText(context, parentToolCallId, chunk)
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
@@ -1819,6 +1899,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
const id: string | undefined = toolData.id || data?.toolCallId
const name: string | undefined = toolData.name || data?.toolName
if (!id || !name) return
const isPartial = toolData.partial === true
// Arguments can come in different locations depending on SSE format
// Check multiple possible locations
@@ -1885,6 +1966,10 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
if (isPartial) {
return
}
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
try {
const def = getTool(name)
@@ -2002,6 +2087,14 @@ const MIN_BATCH_INTERVAL = 16
const MAX_BATCH_INTERVAL = 50
const MAX_QUEUE_SIZE = 5
function stopStreamingUpdates() {
if (streamingUpdateRAF !== null) {
cancelAnimationFrame(streamingUpdateRAF)
streamingUpdateRAF = null
}
streamingUpdateQueue.clear()
}
function createOptimizedContentBlocks(contentBlocks: any[]): any[] {
const result: any[] = new Array(contentBlocks.length)
for (let i = 0; i < contentBlocks.length; i++) {
@@ -2109,6 +2202,7 @@ const initialState = {
messages: [] as CopilotMessage[],
checkpoints: [] as any[],
messageCheckpoints: {} as Record<string, any[]>,
messageSnapshots: {} as Record<string, WorkflowState>,
isLoading: false,
isLoadingChats: false,
isLoadingCheckpoints: false,
@@ -2132,6 +2226,7 @@ const initialState = {
suppressAutoSelect: false,
autoAllowedTools: [] as string[],
messageQueue: [] as import('./types').QueuedMessage[],
suppressAbortContinueOption: false,
}
export const useCopilotStore = create<CopilotStore>()(
@@ -2154,7 +2249,7 @@ export const useCopilotStore = create<CopilotStore>()(
// Abort all in-progress tools and clear any diff preview
abortAllInProgressTools(set, get)
try {
useWorkflowDiffStore.getState().clearDiff()
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
} catch {}
set({
@@ -2188,7 +2283,7 @@ export const useCopilotStore = create<CopilotStore>()(
// Abort in-progress tools and clear diff when changing chats
abortAllInProgressTools(set, get)
try {
useWorkflowDiffStore.getState().clearDiff()
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
} catch {}
// Restore plan content and config (mode/model) from selected chat
@@ -2281,7 +2376,7 @@ export const useCopilotStore = create<CopilotStore>()(
// Abort in-progress tools and clear diff on new chat
abortAllInProgressTools(set, get)
try {
useWorkflowDiffStore.getState().clearDiff()
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
} catch {}
// Background-save the current chat before clearing (optimistic)
@@ -2454,36 +2549,64 @@ export const useCopilotStore = create<CopilotStore>()(
// Send a message (streaming only)
sendMessage: async (message: string, options = {}) => {
const { workflowId, currentChat, mode, revertState, isSendingMessage } = get()
const {
workflowId,
currentChat,
mode,
revertState,
isSendingMessage,
abortController: activeAbortController,
} = get()
const {
stream = true,
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 (isSendingMessage) {
get().addToQueue(message, { fileAttachments, contexts, messageId })
logger.info('[Copilot] Message queued (already sending)', {
queueLength: get().messageQueue.length + 1,
// 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,
})
return
set({ isSendingMessage: false })
} else if (isSendingMessage && activeAbortController?.signal.aborted) {
logger.warn('[Copilot] sendMessage: aborted controller detected, clearing', {
originalMessageId: messageId,
})
set({ isSendingMessage: false, abortController: null })
} else if (isSendingMessage) {
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 abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController })
const nextAbortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController: nextAbortController })
const userMessage = createUserMessage(message, fileAttachments, contexts, messageId)
const streamingMessage = createStreamingMessage()
const snapshot = workflowId ? buildCheckpointWorkflowState(workflowId) : null
if (snapshot) {
set((state) => ({
messageSnapshots: { ...state.messageSnapshots, [userMessage.id]: snapshot },
}))
}
let newMessages: CopilotMessage[]
if (revertState) {
@@ -2548,7 +2671,7 @@ export const useCopilotStore = create<CopilotStore>()(
}
// Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' =
const apiMode: CopilotTransportMode =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
// Extract slash commands from contexts (lowercase) and filter them out from contexts
@@ -2570,7 +2693,7 @@ export const useCopilotStore = create<CopilotStore>()(
fileAttachments,
contexts: filteredContexts,
commands: commands?.length ? commands : undefined,
abortSignal: abortController.signal,
abortSignal: nextAbortController.signal,
})
if (result.success && result.stream) {
@@ -2640,12 +2763,14 @@ export const useCopilotStore = create<CopilotStore>()(
},
// Abort streaming
abortMessage: () => {
abortMessage: (options?: { suppressContinueOption?: boolean }) => {
const { abortController, isSendingMessage, messages } = get()
if (!isSendingMessage || !abortController) return
set({ isAborting: true })
const suppressContinueOption = options?.suppressContinueOption === true
set({ isAborting: true, suppressAbortContinueOption: suppressContinueOption })
try {
abortController.abort()
stopStreamingUpdates()
const lastMessage = messages[messages.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
const textContent =
@@ -2653,10 +2778,21 @@ export const useCopilotStore = create<CopilotStore>()(
?.filter((b) => b.type === 'text')
.map((b: any) => b.content)
.join('') || ''
const nextContentBlocks = suppressContinueOption
? (lastMessage.contentBlocks ?? [])
: appendContinueOptionBlock(
lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : []
)
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === lastMessage.id
? { ...msg, content: textContent.trim() || 'Message was aborted' }
? {
...msg,
content: suppressContinueOption
? textContent.trim() || 'Message was aborted'
: appendContinueOption(textContent.trim() || 'Message was aborted'),
contentBlocks: nextContentBlocks,
}
: msg
),
isSendingMessage: false,
@@ -2955,6 +3091,10 @@ export const useCopilotStore = create<CopilotStore>()(
if (!workflowId) return
set({ isRevertingCheckpoint: true, checkpointError: null })
try {
const { messageCheckpoints } = get()
const checkpointMessageId = Object.entries(messageCheckpoints).find(([, cps]) =>
(cps || []).some((cp: any) => cp?.id === checkpointId)
)?.[0]
const response = await fetch('/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -3000,6 +3140,11 @@ export const useCopilotStore = create<CopilotStore>()(
},
})
}
if (checkpointMessageId) {
const { messageCheckpoints: currentCheckpoints } = get()
const updatedCheckpoints = { ...currentCheckpoints, [checkpointMessageId]: [] }
set({ messageCheckpoints: updatedCheckpoints })
}
set({ isRevertingCheckpoint: false })
} catch (error) {
set({
@@ -3013,6 +3158,10 @@ export const useCopilotStore = create<CopilotStore>()(
const { messageCheckpoints } = get()
return messageCheckpoints[messageId] || []
},
saveMessageCheckpoint: async (messageId: string) => {
if (!messageId) return false
return saveMessageCheckpoint(messageId, get, set)
},
// Handle streaming response
handleStreamingResponse: async (
@@ -3060,7 +3209,19 @@ export const useCopilotStore = create<CopilotStore>()(
try {
for await (const data of parseSSEStream(reader, decoder)) {
const { abortController } = get()
if (abortController?.signal.aborted) break
if (abortController?.signal.aborted) {
context.wasAborted = true
const { suppressAbortContinueOption } = get()
context.suppressContinueOption = suppressAbortContinueOption === true
if (suppressAbortContinueOption) {
set({ suppressAbortContinueOption: false })
}
context.pendingContent = ''
finalizeThinkingBlock(context)
stopStreamingUpdates()
reader.cancel()
break
}
// Log SSE events for debugging
logger.info('[SSE] Received event', {
@@ -3160,7 +3321,9 @@ export const useCopilotStore = create<CopilotStore>()(
if (context.streamComplete) break
}
if (sseHandlers.stream_end) sseHandlers.stream_end({}, context, get, set)
if (!context.wasAborted && sseHandlers.stream_end) {
sseHandlers.stream_end({}, context, get, set)
}
if (streamingUpdateRAF !== null) {
cancelAnimationFrame(streamingUpdateRAF)
@@ -3177,6 +3340,9 @@ export const useCopilotStore = create<CopilotStore>()(
: block
)
}
if (context.wasAborted && !context.suppressContinueOption) {
sanitizedContentBlocks = appendContinueOptionBlock(sanitizedContentBlocks)
}
if (context.contentBlocks) {
context.contentBlocks.forEach((block) => {
@@ -3187,21 +3353,37 @@ export const useCopilotStore = create<CopilotStore>()(
}
const finalContent = stripTodoTags(context.accumulatedContent.toString())
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === assistantMessageId
? {
...msg,
content: finalContent,
contentBlocks: sanitizedContentBlocks,
}
: msg
),
isSendingMessage: false,
isAborting: false,
abortController: null,
currentUserMessageId: null,
}))
const finalContentWithOptions =
context.wasAborted && !context.suppressContinueOption
? appendContinueOption(finalContent)
: finalContent
set((state) => {
const snapshotId = state.currentUserMessageId
const nextSnapshots =
snapshotId && state.messageSnapshots[snapshotId]
? (() => {
const updated = { ...state.messageSnapshots }
delete updated[snapshotId]
return updated
})()
: state.messageSnapshots
return {
messages: state.messages.map((msg) =>
msg.id === assistantMessageId
? {
...msg,
content: finalContentWithOptions,
contentBlocks: sanitizedContentBlocks,
}
: msg
),
isSendingMessage: false,
isAborting: false,
abortController: null,
currentUserMessageId: null,
messageSnapshots: nextSnapshots,
}
})
if (context.newChatId && !get().currentChat) {
await get().handleNewChatCreation(context.newChatId)
@@ -3709,7 +3891,7 @@ export const useCopilotStore = create<CopilotStore>()(
// If currently sending, abort and send this one
const { isSendingMessage } = get()
if (isSendingMessage) {
get().abortMessage()
get().abortMessage({ suppressContinueOption: true })
// Wait a tick for abort to complete
await new Promise((resolve) => setTimeout(resolve, 50))
}

View File

@@ -1,4 +1,9 @@
import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools/client/base-tool'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
export type ToolState = ClientToolCallState
@@ -91,33 +96,9 @@ import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
export type CopilotChat = ApiCopilotChat
export type CopilotMode = 'ask' | 'build' | 'plan'
export interface CopilotState {
mode: CopilotMode
selectedModel:
| 'gpt-5-fast'
| 'gpt-5'
| 'gpt-5-medium'
| 'gpt-5-high'
| 'gpt-5.1-fast'
| 'gpt-5.1'
| 'gpt-5.1-medium'
| 'gpt-5.1-high'
| 'gpt-5-codex'
| 'gpt-5.1-codex'
| 'gpt-5.2'
| 'gpt-5.2-codex'
| 'gpt-5.2-pro'
| 'gpt-4o'
| 'gpt-4.1'
| 'o3'
| 'claude-4-sonnet'
| 'claude-4.5-haiku'
| 'claude-4.5-sonnet'
| 'claude-4.5-opus'
| 'claude-4.1-opus'
| 'gemini-3-pro'
selectedModel: CopilotModelId
agentPrefetch: boolean
enabledModels: string[] | null // Null means not loaded yet, array of model IDs when loaded
isCollapsed: boolean
@@ -129,6 +110,7 @@ export interface CopilotState {
checkpoints: any[]
messageCheckpoints: Record<string, any[]>
messageSnapshots: Record<string, WorkflowState>
isLoading: boolean
isLoadingChats: boolean
@@ -137,6 +119,8 @@ export interface CopilotState {
isSaving: boolean
isRevertingCheckpoint: boolean
isAborting: boolean
/** Skip adding Continue option on abort for queued send-now */
suppressAbortContinueOption?: boolean
error: string | null
saveError: string | null
@@ -195,9 +179,10 @@ export interface CopilotActions {
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
messageId?: string
queueIfBusy?: boolean
}
) => Promise<void>
abortMessage: () => void
abortMessage: (options?: { suppressContinueOption?: boolean }) => void
sendImplicitFeedback: (
implicitFeedback: string,
toolCallState?: 'accepted' | 'rejected' | 'error'
@@ -215,6 +200,7 @@ export interface CopilotActions {
loadMessageCheckpoints: (chatId: string) => Promise<void>
revertToCheckpoint: (checkpointId: string) => Promise<void>
getCheckpointsForMessage: (messageId: string) => any[]
saveMessageCheckpoint: (messageId: string) => Promise<boolean>
clearMessages: () => void
clearError: () => void

View File

@@ -23,6 +23,32 @@ import {
const logger = createLogger('WorkflowDiffStore')
const diffEngine = new WorkflowDiffEngine()
/**
* Detects when a diff contains no meaningful changes.
*/
function isEmptyDiffAnalysis(
diffAnalysis?: {
new_blocks?: string[]
edited_blocks?: string[]
deleted_blocks?: string[]
field_diffs?: Record<string, { changed_fields: string[] }>
edge_diff?: { new_edges?: string[]; deleted_edges?: string[] }
} | null
): boolean {
if (!diffAnalysis) return false
const hasBlockChanges =
(diffAnalysis.new_blocks?.length || 0) > 0 ||
(diffAnalysis.edited_blocks?.length || 0) > 0 ||
(diffAnalysis.deleted_blocks?.length || 0) > 0
const hasEdgeChanges =
(diffAnalysis.edge_diff?.new_edges?.length || 0) > 0 ||
(diffAnalysis.edge_diff?.deleted_edges?.length || 0) > 0
const hasFieldChanges = Object.values(diffAnalysis.field_diffs || {}).some(
(diff) => (diff?.changed_fields?.length || 0) > 0
)
return !hasBlockChanges && !hasEdgeChanges && !hasFieldChanges
}
export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActions>()(
devtools(
(set, get) => {
@@ -75,6 +101,24 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
throw new Error(errorMessage)
}
const diffAnalysisResult = diffResult.diff.diffAnalysis || null
if (isEmptyDiffAnalysis(diffAnalysisResult)) {
logger.info('No workflow diff detected; skipping diff view')
diffEngine.clearDiff()
batchedUpdate({
hasActiveDiff: false,
isShowingDiff: false,
isDiffReady: false,
baselineWorkflow: null,
baselineWorkflowId: null,
diffAnalysis: null,
diffMetadata: null,
diffError: null,
_triggerMessageId: null,
})
return
}
const candidateState = diffResult.diff.proposedState
// Validate proposed workflow using serializer round-trip
@@ -103,12 +147,22 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
isDiffReady: true,
baselineWorkflow: baselineWorkflow,
baselineWorkflowId,
diffAnalysis: diffResult.diff.diffAnalysis || null,
diffAnalysis: diffAnalysisResult,
diffMetadata: diffResult.diff.metadata,
diffError: null,
_triggerMessageId: triggerMessageId ?? null,
})
if (triggerMessageId) {
import('@/stores/panel/copilot/store')
.then(({ useCopilotStore }) =>
useCopilotStore.getState().saveMessageCheckpoint(triggerMessageId)
)
.catch((error) => {
logger.warn('Failed to save checkpoint for diff', { error })
})
}
logger.info('Workflow diff applied optimistically', {
workflowId: activeWorkflowId,
blocks: Object.keys(candidateState.blocks || {}).length,

View File

@@ -26,11 +26,16 @@
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../next-env.d.ts",
"telemetry.config.js",
"trigger.config.ts",
".next/dev/types/**/*.ts"
"trigger.config.ts"
],
"exclude": ["node_modules", "vitest.config.ts", "vitest.setup.ts"]
"exclude": [
"node_modules",
".next",
"**/*.test.ts",
"**/*.test.tsx",
"vitest.config.ts",
"vitest.setup.ts"
]
}

View File

@@ -257,6 +257,7 @@
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
},
},

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow_mcp_server" ADD COLUMN "is_public" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1002,6 +1002,13 @@
"when": 1768518143986,
"tag": "0143_puzzling_xorn",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1768582494384,
"tag": "0144_old_killer_shrike",
"breakpoints": true
}
]
}

View File

@@ -32,6 +32,7 @@
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}

View File

@@ -1734,7 +1734,8 @@ export const ssoProvider = pgTable(
/**
* Workflow MCP Servers - User-created MCP servers that expose workflows as tools.
* These servers are accessible by external MCP clients via API key authentication.
* These servers are accessible by external MCP clients via API key authentication,
* or publicly if isPublic is set to true.
*/
export const workflowMcpServer = pgTable(
'workflow_mcp_server',
@@ -1748,6 +1749,7 @@ export const workflowMcpServer = pgTable(
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
isPublic: boolean('is_public').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},