mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-16 18:38:08 -05:00
Compare commits
6 Commits
fix/copilo
...
feat/readm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c115ced01a | ||
|
|
80d4853d70 | ||
|
|
e589aa1f63 | ||
|
|
e87f2facf6 | ||
|
|
ce3ddb6ba0 | ||
|
|
8361931cdf |
@@ -10,7 +10,7 @@
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a> <a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/DeepWiki-1E90FF.svg" alt="DeepWiki"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -35,10 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
||||
|
||||
const startBlock = blocks.find(
|
||||
(block) =>
|
||||
block.type === 'starter' || block.type === 'start_trigger' || block.type === 'input_trigger'
|
||||
)
|
||||
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
|
||||
|
||||
if (!startBlock) {
|
||||
return []
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -77,7 +77,7 @@ export function DeleteChunkModal({
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' disabled={isDeleting} onClick={onClose}>
|
||||
<Button variant='default' disabled={isDeleting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDeleteChunk} disabled={isDeleting}>
|
||||
|
||||
@@ -392,7 +392,7 @@ export function DocumentTagsModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Document Tags</span>
|
||||
@@ -486,7 +486,7 @@ export function DocumentTagsModal({
|
||||
/>
|
||||
)}
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
<span className='text-[12px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
@@ -1208,15 +1161,19 @@ export function Document({
|
||||
<ModalHeader>Delete Document</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete "{effectiveDocumentName}"? This will permanently
|
||||
delete the document and all {documentData?.chunkCount ?? 0} chunk
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{effectiveDocumentName}
|
||||
</span>
|
||||
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
|
||||
chunk
|
||||
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => setShowDeleteDocumentDialog(false)}
|
||||
disabled={isDeletingDocument}
|
||||
>
|
||||
|
||||
@@ -1523,15 +1523,16 @@ export function KnowledgeBase({
|
||||
<ModalHeader>Delete Knowledge Base</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete "{knowledgeBaseName}"? This will permanently delete
|
||||
the knowledge base and all {pagination.total} document
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
|
||||
This will permanently delete the knowledge base and all {pagination.total} document
|
||||
{pagination.total === 1 ? '' : 's'} within it.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
@@ -1549,14 +1550,16 @@ export function KnowledgeBase({
|
||||
<ModalHeader>Delete Document</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete "
|
||||
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}"?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}
|
||||
</span>
|
||||
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
setShowDeleteDocumentModal(false)
|
||||
setDocumentToDelete(null)
|
||||
@@ -1582,7 +1585,7 @@ export function KnowledgeBase({
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={() => setShowBulkDeleteModal(false)}>
|
||||
<Button variant='default' onClick={() => setShowBulkDeleteModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={confirmBulkDelete} disabled={isBulkOperating}>
|
||||
|
||||
@@ -221,14 +221,14 @@ export function AddDocumentsModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Add Documents</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
{fileError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
@@ -336,7 +336,7 @@ export function AddDocumentsModal({
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{uploadError ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{uploadError.message}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -306,7 +306,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Tags</span>
|
||||
@@ -400,7 +400,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
}}
|
||||
/>
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
<span className='text-[12px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
@@ -417,7 +417,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
placeholder='Select type'
|
||||
/>
|
||||
{!hasAvailableSlots(createTagForm.fieldType) && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
<span className='text-[12px] text-[var(--text-error)]'>
|
||||
No available slots for this type. Choose a different type.
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function RenameDocumentModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Rename Document</ModalHeader>
|
||||
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
@@ -108,7 +108,7 @@ export function RenameDocumentModal({
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{error ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -332,7 +332,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='lg'>
|
||||
<ModalHeader>Create Knowledge Base</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
@@ -528,7 +528,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
)}
|
||||
|
||||
{fileError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -537,7 +537,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{submitStatus?.type === 'error' || uploadError ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{uploadError?.message || submitStatus?.message}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -38,7 +38,7 @@ export function DeleteKnowledgeBaseModal({
|
||||
}: DeleteKnowledgeBaseModalProps) {
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Knowledge Base</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -55,7 +55,7 @@ export function DeleteKnowledgeBaseModal({
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={onClose} disabled={isDeleting}>
|
||||
<Button variant='default' onClick={onClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={onConfirm} disabled={isDeleting}>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function EditKnowledgeBaseModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Edit Knowledge Base</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
@@ -118,7 +118,7 @@ export function EditKnowledgeBaseModal({
|
||||
data-form-type='other'
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{errors.name.message}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ export function EditKnowledgeBaseModal({
|
||||
className={cn(errors.description && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -143,7 +143,7 @@ export function EditKnowledgeBaseModal({
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{error ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -1261,7 +1261,7 @@ export function NotificationSettings({
|
||||
</Modal>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Notification</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useCallback } from 'react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
||||
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -97,7 +98,7 @@ export const ActionBar = memo(
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
|
||||
const isStartBlock = isValidStartBlockType(blockType)
|
||||
const isResponseBlock = blockType === 'response'
|
||||
const isNoteBlock = blockType === 'note'
|
||||
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
|
||||
/**
|
||||
* Block information for context menu actions
|
||||
@@ -73,9 +74,7 @@ export function BlockMenu({
|
||||
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
||||
const allDisabled = selectedBlocks.every((b) => !b.enabled)
|
||||
|
||||
const hasStarterBlock = selectedBlocks.some(
|
||||
(b) => b.type === 'starter' || b.type === 'start_trigger'
|
||||
)
|
||||
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
|
||||
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
|
||||
const isSubflow =
|
||||
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
|
||||
export { useIdentifierValidation } from './use-identifier-validation'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -17,11 +17,18 @@ 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 { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
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 +41,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 +61,6 @@ export function FormDeploy({
|
||||
onDeploymentComplete,
|
||||
onValidationChange,
|
||||
onSubmittingChange,
|
||||
onExistingFormChange,
|
||||
formSubmitting,
|
||||
setFormSubmitting,
|
||||
onDeployed,
|
||||
@@ -95,8 +74,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 +81,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,85 +106,54 @@ 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')
|
||||
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
|
||||
|
||||
if (startBlock) {
|
||||
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')
|
||||
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 +173,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 +203,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 +230,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 +257,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 +270,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 +293,8 @@ export function FormDeploy({
|
||||
password,
|
||||
allowedEmails,
|
||||
isIdentifierValid,
|
||||
createForm,
|
||||
updateForm,
|
||||
createFormMutation,
|
||||
updateFormMutation,
|
||||
onDeployed,
|
||||
onDeploymentComplete,
|
||||
]
|
||||
@@ -353,9 +304,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 +315,7 @@ export function FormDeploy({
|
||||
} catch (err) {
|
||||
logger.error('Error deleting form:', err)
|
||||
}
|
||||
}, [existingForm, deleteForm, onExistingFormChange])
|
||||
}, [existingForm, deleteFormMutation, workflowId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
@@ -308,6 +308,7 @@ export function OAuthRequiredModal({
|
||||
serviceId,
|
||||
newScopes = [],
|
||||
}: OAuthRequiredModalProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { baseProvider } = parseProvider(provider)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
@@ -348,23 +349,24 @@ export function OAuthRequiredModal({
|
||||
}, [requiredScopes, newScopesSet])
|
||||
|
||||
const handleConnectDirectly = async () => {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const providerId = getProviderIdFromServiceId(serviceId)
|
||||
|
||||
onClose()
|
||||
|
||||
logger.info('Linking OAuth2:', {
|
||||
providerId,
|
||||
requiredScopes,
|
||||
})
|
||||
|
||||
if (providerId === 'trello') {
|
||||
onClose()
|
||||
window.location.href = '/api/auth/trello/authorize'
|
||||
return
|
||||
}
|
||||
|
||||
if (providerId === 'shopify') {
|
||||
// Pass the current URL so we can redirect back after OAuth
|
||||
onClose()
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
|
||||
return
|
||||
@@ -374,8 +376,10 @@ export function OAuthRequiredModal({
|
||||
providerId,
|
||||
callbackURL: window.location.href,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error initiating OAuth flow:', { error })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
logger.error('Error initiating OAuth flow:', { error: err })
|
||||
setError('Failed to connect. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,10 +429,12 @@ export function OAuthRequiredModal({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={onClose}>
|
||||
<Button variant='default' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' type='button' onClick={handleConnectDirectly}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AlertCircle, Wand2 } from 'lucide-react'
|
||||
import { AlertCircle, ArrowUp } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -878,35 +879,53 @@ try {
|
||||
JSON Schema
|
||||
</Label>
|
||||
{schemaError && (
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='truncate'>{schemaError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
|
||||
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
|
||||
{!isSchemaPromptActive ? (
|
||||
<button
|
||||
type='button'
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleSchemaWandClick}
|
||||
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
|
||||
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
|
||||
aria-label='Generate schema with AI'
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
|
||||
</button>
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<input
|
||||
ref={schemaPromptInputRef}
|
||||
type='text'
|
||||
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
|
||||
onChange={(e) => handleSchemaPromptChange(e.target.value)}
|
||||
onBlur={handleSchemaPromptBlur}
|
||||
onKeyDown={handleSchemaPromptKeyDown}
|
||||
disabled={schemaGeneration.isStreaming}
|
||||
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
|
||||
placeholder='Describe schema...'
|
||||
/>
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={schemaPromptInputRef}
|
||||
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
|
||||
onChange={(e) => handleSchemaPromptChange(e.target.value)}
|
||||
onBlur={handleSchemaPromptBlur}
|
||||
onKeyDown={handleSchemaPromptKeyDown}
|
||||
disabled={schemaGeneration.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
schemaGeneration.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!schemaPromptInput.trim() || schemaGeneration.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSchemaPromptSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -952,35 +971,53 @@ try {
|
||||
Code
|
||||
</Label>
|
||||
{codeError && !codeGeneration.isStreaming && (
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='truncate'>{codeError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
|
||||
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
|
||||
{!isCodePromptActive ? (
|
||||
<button
|
||||
type='button'
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleCodeWandClick}
|
||||
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
|
||||
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
|
||||
aria-label='Generate code with AI'
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
|
||||
</button>
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<input
|
||||
ref={codePromptInputRef}
|
||||
type='text'
|
||||
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
|
||||
onChange={(e) => handleCodePromptChange(e.target.value)}
|
||||
onBlur={handleCodePromptBlur}
|
||||
onKeyDown={handleCodePromptKeyDown}
|
||||
disabled={codeGeneration.isStreaming}
|
||||
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
|
||||
placeholder='Describe code...'
|
||||
/>
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={codePromptInputRef}
|
||||
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
|
||||
onChange={(e) => handleCodePromptChange(e.target.value)}
|
||||
onBlur={handleCodePromptBlur}
|
||||
onKeyDown={handleCodePromptKeyDown}
|
||||
disabled={codeGeneration.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
codeGeneration.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!codePromptInput.trim() || codeGeneration.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCodePromptSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -556,14 +556,17 @@ export function Panel() {
|
||||
<ModalHeader>Delete Workflow</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Deleting this workflow will permanently remove all associated blocks, executions, and
|
||||
configuration.{' '}
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{currentWorkflow?.name ?? 'this workflow'}
|
||||
</span>
|
||||
? This will permanently remove all associated blocks, executions, and configuration.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
@@ -26,9 +27,7 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
|
||||
const accessibleIds = new Set<string>(ancestorIds)
|
||||
accessibleIds.add(blockId)
|
||||
|
||||
const starterBlock = Object.values(blocks).find(
|
||||
(block) => block.type === 'starter' || block.type === 'start_trigger'
|
||||
)
|
||||
const starterBlock = Object.values(blocks).find((block) => isValidStartBlockType(block.type))
|
||||
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
||||
accessibleIds.add(starterBlock.id)
|
||||
}
|
||||
|
||||
@@ -420,7 +420,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Help & Support</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
|
||||
@@ -1069,7 +1069,7 @@ export function AccessControl() {
|
||||
</Modal>
|
||||
|
||||
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -1185,7 +1185,7 @@ export function AccessControl() {
|
||||
</div>
|
||||
|
||||
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create Permission Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
@@ -1237,7 +1237,7 @@ export function AccessControl() {
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!deletingGroup} onOpenChange={() => setDeletingGroup(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Permission Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -392,7 +392,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function CreateApiKeyModal({
|
||||
<>
|
||||
{/* Create API Key Dialog */}
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -176,7 +176,7 @@ export function CreateApiKeyModal({
|
||||
data-form-type='other'
|
||||
/>
|
||||
{createError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
@@ -215,7 +215,7 @@ export function CreateApiKeyModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -306,7 +306,7 @@ export function BYOK() {
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!deleteConfirmProvider} onOpenChange={() => setDeleteConfirmProvider(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API Key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -211,7 +211,7 @@ export function Copilot() {
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -273,7 +273,7 @@ export function Copilot() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -310,7 +310,7 @@ export function Copilot() {
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -824,7 +824,7 @@ export function CredentialSets() {
|
||||
|
||||
{/* Create Polling Group Modal */}
|
||||
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create Polling Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
@@ -897,7 +897,7 @@ export function CredentialSets() {
|
||||
|
||||
{/* Leave Confirmation Modal */}
|
||||
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Leave Polling Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -925,7 +925,7 @@ export function CredentialSets() {
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Polling Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -206,7 +206,7 @@ export function CustomTools() {
|
||||
/>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Custom Tool</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -821,7 +821,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
</div>
|
||||
|
||||
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -390,7 +390,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
|
||||
</div>
|
||||
|
||||
<Modal open={showDisconnectDialog} onOpenChange={setShowDisconnectDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Disconnect Service</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -1071,7 +1170,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
</div>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -245,10 +245,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
||||
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
||||
periodEndDate
|
||||
)}, then downgrade to free plan.`}{' '}
|
||||
{!isCancelAtPeriodEnd && (
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
)}
|
||||
)}, then downgrade to free plan. You can restore your subscription at any time.`}
|
||||
</p>
|
||||
|
||||
{!isCancelAtPeriodEnd && (
|
||||
@@ -266,7 +263,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
|
||||
@@ -33,13 +33,19 @@ export function RemoveMemberDialog({
|
||||
}: RemoveMemberDialogProps) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{isSelfRemoval
|
||||
? 'Are you sure you want to leave this organization? You will lose access to all team resources.'
|
||||
: `Are you sure you want to remove ${memberName} from the team?`}{' '}
|
||||
{isSelfRemoval ? (
|
||||
'Are you sure you want to leave this organization? You will lose access to all team resources.'
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to remove{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{memberName}</span> from
|
||||
the team?
|
||||
</>
|
||||
)}{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
|
||||
@@ -71,7 +77,7 @@ export function RemoveMemberDialog({
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={onCancel}>
|
||||
<Button variant='default' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>
|
||||
|
||||
@@ -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>
|
||||
@@ -246,7 +532,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</div>
|
||||
|
||||
<Modal open={!!toolToDelete} onOpenChange={(open) => !open && setToolToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Remove Workflow</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -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>
|
||||
@@ -630,7 +1109,7 @@ export function WorkflowMcpServers() {
|
||||
</div>
|
||||
|
||||
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function DeleteModal({
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -111,7 +111,7 @@ export function DeleteModal({
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={onClose} disabled={isDeleting}>
|
||||
<Button variant='default' onClick={onClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={onConfirm} disabled={isDeleting}>
|
||||
|
||||
@@ -607,7 +607,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
onOpenChange(newOpen)
|
||||
}}
|
||||
>
|
||||
<ModalContent className='w-[500px]'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
|
||||
|
||||
<form
|
||||
@@ -740,7 +740,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
|
||||
{/* Remove Member Confirmation Dialog */}
|
||||
<Modal open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Remove Member</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -773,7 +773,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
|
||||
{/* Remove Invitation Confirmation Dialog */}
|
||||
<Modal open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Cancel Invitation</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
ChevronDown,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
PanelLeft,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -143,6 +148,9 @@ export function WorkspaceHeader({
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
|
||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false)
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
const [leaveTarget, setLeaveTarget] = useState<Workspace | null>(null)
|
||||
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [isListRenaming, setIsListRenaming] = useState(false)
|
||||
@@ -278,13 +286,35 @@ export function WorkspaceHeader({
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles leave action from context menu
|
||||
* Handles leave action from context menu - shows confirmation modal
|
||||
*/
|
||||
const handleLeaveAction = async () => {
|
||||
if (!capturedWorkspaceRef.current || !onLeaveWorkspace) return
|
||||
const handleLeaveAction = () => {
|
||||
if (!capturedWorkspaceRef.current) return
|
||||
|
||||
await onLeaveWorkspace(capturedWorkspaceRef.current.id)
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
const workspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
|
||||
if (workspace) {
|
||||
setLeaveTarget(workspace)
|
||||
setIsLeaveModalOpen(true)
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle leave workspace after confirmation
|
||||
*/
|
||||
const handleLeaveWorkspace = async () => {
|
||||
if (!leaveTarget || !onLeaveWorkspace) return
|
||||
|
||||
setIsLeaving(true)
|
||||
try {
|
||||
await onLeaveWorkspace(leaveTarget.id)
|
||||
setIsLeaveModalOpen(false)
|
||||
setLeaveTarget(null)
|
||||
} catch (error) {
|
||||
logger.error('Error leaving workspace:', error)
|
||||
} finally {
|
||||
setIsLeaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,6 +603,32 @@ export function WorkspaceHeader({
|
||||
itemType='workspace'
|
||||
itemName={deleteTarget?.name || activeWorkspaceFull?.name || activeWorkspace?.name}
|
||||
/>
|
||||
{/* Leave Confirmation Modal */}
|
||||
<Modal open={isLeaveModalOpen} onOpenChange={() => setIsLeaveModalOpen(false)}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Leave Workspace</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to leave{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{leaveTarget?.name}</span>?
|
||||
You will lose access to all workflows and data in this workspace.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => setIsLeaveModalOpen(false)}
|
||||
disabled={isLeaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleLeaveWorkspace} disabled={isLeaving}>
|
||||
{isLeaving ? 'Leaving...' : 'Leave Workspace'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
262
apps/sim/hooks/queries/chats.ts
Normal file
262
apps/sim/hooks/queries/chats.ts
Normal 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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
441
apps/sim/hooks/queries/deployments.ts
Normal file
441
apps/sim/hooks/queries/deployments.ts
Normal 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),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
295
apps/sim/hooks/queries/forms.ts
Normal file
295
apps/sim/hooks/queries/forms.ts
Normal 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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type GetBlockUpstreamReferencesResultType,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
@@ -140,9 +141,7 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
|
||||
const accessibleIds = new Set<string>(ancestorIds)
|
||||
accessibleIds.add(blockId)
|
||||
|
||||
const starterBlock = Object.values(blocks).find(
|
||||
(b) => b.type === 'starter' || b.type === 'start_trigger'
|
||||
)
|
||||
const starterBlock = Object.values(blocks).find((b) => isValidStartBlockType(b.type))
|
||||
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
||||
accessibleIds.add(starterBlock.id)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import type { McpToolSchema } from './types'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,7 @@ export function extractInputFieldsFromBlocks(
|
||||
// Find trigger block
|
||||
const triggerEntry = Object.entries(blocks).find(([, block]) => {
|
||||
const b = block as Record<string, unknown>
|
||||
return b.type === 'start_trigger' || b.type === 'input_trigger' || b.type === 'starter'
|
||||
return typeof b.type === 'string' && isValidStartBlockType(b.type)
|
||||
})
|
||||
|
||||
if (!triggerEntry) return []
|
||||
|
||||
21
apps/sim/lib/workflows/triggers/start-block-types.ts
Normal file
21
apps/sim/lib/workflows/triggers/start-block-types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Valid start block types that can trigger a workflow
|
||||
* This module is kept lightweight with no dependencies to avoid circular imports
|
||||
*/
|
||||
export const VALID_START_BLOCK_TYPES = [
|
||||
'starter',
|
||||
'start',
|
||||
'start_trigger',
|
||||
'api',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
] as const
|
||||
|
||||
export type ValidStartBlockType = (typeof VALID_START_BLOCK_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Check if a block type is a valid start block type
|
||||
*/
|
||||
export function isValidStartBlockType(blockType: string): blockType is ValidStartBlockType {
|
||||
return VALID_START_BLOCK_TYPES.includes(blockType as ValidStartBlockType)
|
||||
}
|
||||
18
apps/sim/lib/workflows/triggers/trigger-utils.server.ts
Normal file
18
apps/sim/lib/workflows/triggers/trigger-utils.server.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import {
|
||||
type StartBlockCandidate,
|
||||
StartBlockPath,
|
||||
@@ -11,27 +12,6 @@ import { getTrigger } from '@/triggers'
|
||||
|
||||
const logger = createLogger('TriggerUtils')
|
||||
|
||||
/**
|
||||
* Valid start block types that can trigger a workflow
|
||||
*/
|
||||
export const VALID_START_BLOCK_TYPES = [
|
||||
'starter',
|
||||
'start',
|
||||
'start_trigger',
|
||||
'api',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
] as const
|
||||
|
||||
export type ValidStartBlockType = (typeof VALID_START_BLOCK_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Check if a block type is a valid start block type
|
||||
*/
|
||||
export function isValidStartBlockType(blockType: string): blockType is ValidStartBlockType {
|
||||
return VALID_START_BLOCK_TYPES.includes(blockType as ValidStartBlockType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workflow state has a valid start block
|
||||
*/
|
||||
@@ -90,7 +70,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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -257,6 +257,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
},
|
||||
|
||||
1
packages/db/migrations/0144_old_killer_shrike.sql
Normal file
1
packages/db/migrations/0144_old_killer_shrike.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow_mcp_server" ADD COLUMN "is_public" boolean DEFAULT false NOT NULL;
|
||||
10304
packages/db/migrations/meta/0144_snapshot.json
Normal file
10304
packages/db/migrations/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user