improvement(deployments): simplify deployments for chat and indicate active version (#1730)

* improvement(deployment-ux): deployment should indicate and make details configurable when activating previous version

* fix activation UI

* remove redundant code

* revert pulsing dot

* fix redeploy bug

* bill workspace owner for deployed chat

* deployed chat

* fix bugs

* fix tests, address greptile

* fix

* ui bug to load api key

* fix qdrant fetch tool
This commit is contained in:
Vikhyath Mondreti
2025-10-25 13:55:34 -10:00
committed by GitHub
parent ce4893a53c
commit ad7b791242
19 changed files with 1445 additions and 1119 deletions

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { chat, workflow, workspace } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
@@ -94,11 +94,12 @@ export async function POST(
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
}
// Get the workflow for this chat
// Get the workflow and workspace owner for this chat
const workflowResult = await db
.select({
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
variables: workflow.variables,
})
.from(workflow)
.where(eq(workflow.id, deployment.workflowId))
@@ -109,6 +110,22 @@ export async function POST(
return addCorsHeaders(createErrorResponse('Chat workflow is not available', 503), request)
}
let workspaceOwnerId = deployment.userId
if (workflowResult[0].workspaceId) {
const workspaceData = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workflowResult[0].workspaceId))
.limit(1)
if (workspaceData.length === 0) {
logger.error(`[${requestId}] Workspace not found for workflow ${deployment.workflowId}`)
return addCorsHeaders(createErrorResponse('Workspace not found', 500), request)
}
workspaceOwnerId = workspaceData[0].ownerId
}
try {
const selectedOutputs: string[] = []
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
@@ -145,16 +162,19 @@ export async function POST(
}
}
const workflowForExecution = {
id: deployment.workflowId,
userId: deployment.userId,
workspaceId: workflowResult[0].workspaceId,
isDeployed: true,
variables: workflowResult[0].variables || {},
}
const stream = await createStreamingResponse({
requestId,
workflow: {
id: deployment.workflowId,
userId: deployment.userId,
workspaceId: workflowResult[0].workspaceId,
isDeployed: true,
},
workflow: workflowForExecution,
input: workflowInput,
executingUserId: deployment.userId,
executingUserId: workspaceOwnerId,
streamConfig: {
selectedOutputs,
isSecureMode: true,

View File

@@ -8,6 +8,7 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { encryptSecret } from '@/lib/utils'
import { deployWorkflow } from '@/lib/workflows/db-helpers'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -134,6 +135,22 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// Redeploy the workflow to ensure latest version is active
const deployResult = await deployWorkflow({
workflowId: existingChat[0].workflowId,
deployedBy: session.user.id,
})
if (!deployResult.success) {
logger.warn(
`Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update`
)
} else {
logger.info(
`Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})`
)
}
let encryptedPassword
if (password) {

View File

@@ -19,6 +19,7 @@ describe('Chat API Route', () => {
const mockCreateErrorResponse = vi.fn()
const mockEncryptSecret = vi.fn()
const mockCheckWorkflowAccessForChatCreation = vi.fn()
const mockDeployWorkflow = vi.fn()
beforeEach(() => {
vi.resetModules()
@@ -76,6 +77,14 @@ describe('Chat API Route', () => {
vi.doMock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
deployWorkflow: mockDeployWorkflow.mockResolvedValue({
success: true,
version: 1,
deployedAt: new Date(),
}),
}))
})
afterEach(() => {
@@ -236,7 +245,7 @@ describe('Chat API Route', () => {
it('should allow chat deployment when user owns workflow directly', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
user: { id: 'user-id', email: 'user@example.com' },
}),
}))
@@ -283,7 +292,7 @@ describe('Chat API Route', () => {
it('should allow chat deployment when user has workspace admin permission', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
user: { id: 'user-id', email: 'user@example.com' },
}),
}))
@@ -393,10 +402,10 @@ describe('Chat API Route', () => {
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should reject if workflow is not deployed', async () => {
it('should auto-deploy workflow if not already deployed', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
user: { id: 'user-id', email: 'user@example.com' },
}),
}))
@@ -415,6 +424,7 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -423,11 +433,11 @@ describe('Chat API Route', () => {
const { POST } = await import('@/app/api/chat/route')
const response = await POST(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Workflow must be deployed before creating a chat',
400
)
expect(response.status).toBe(200)
expect(mockDeployWorkflow).toHaveBeenCalledWith({
workflowId: 'workflow-123',
deployedBy: 'user-id',
})
})
})
})

View File

@@ -9,6 +9,7 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { encryptSecret } from '@/lib/utils'
import { deployWorkflow } from '@/lib/workflows/db-helpers'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -126,11 +127,20 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Workflow not found or access denied', 404)
}
// Verify the workflow is deployed (required for chat deployment)
if (!workflowRecord.isDeployed) {
return createErrorResponse('Workflow must be deployed before creating a chat', 400)
// Always deploy/redeploy the workflow to ensure latest version
const result = await deployWorkflow({
workflowId,
deployedBy: session.user.id,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy workflow', 500)
}
logger.info(
`${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})`
)
// Encrypt password if provided
let encryptedPassword = null
if (authType === 'password' && password) {

View File

@@ -1,10 +1,9 @@
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, desc, eq, sql } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { deployWorkflow } from '@/lib/workflows/db-helpers'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -138,37 +137,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
} catch (_err) {}
logger.debug(`[${requestId}] Getting current workflow state for deployment`)
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (!normalizedData) {
logger.error(`[${requestId}] Failed to load workflow from normalized tables`)
return createErrorResponse('Failed to load workflow state', 500)
}
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
}
logger.debug(`[${requestId}] Current state retrieved from normalized tables:`, {
blocksCount: Object.keys(currentState.blocks).length,
edgesCount: currentState.edges.length,
loopsCount: Object.keys(currentState.loops).length,
parallelsCount: Object.keys(currentState.parallels).length,
})
if (!currentState || !currentState.blocks) {
logger.error(`[${requestId}] Invalid workflow state retrieved`, { currentState })
throw new Error('Invalid workflow state: missing blocks')
}
const deployedAt = new Date()
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)
logger.debug(`[${requestId}] Validating API key for deployment`)
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
let matchedKey: {
@@ -260,46 +229,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse('Unable to determine deploying user', 400)
}
await db.transaction(async (tx) => {
const [{ maxVersion }] = await tx
.select({ maxVersion: sql`COALESCE(MAX("version"), 0)` })
.from(workflowDeploymentVersion)
.where(eq(workflowDeploymentVersion.workflowId, id))
const nextVersion = Number(maxVersion) + 1
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
await tx.insert(workflowDeploymentVersion).values({
id: uuidv4(),
workflowId: id,
version: nextVersion,
state: currentState,
isActive: true,
createdAt: deployedAt,
createdBy: actorUserId,
})
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt,
deployedState: currentState,
}
if (providedApiKey && matchedKey) {
updateData.pinnedApiKeyId = matchedKey.id
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
pinnedApiKeyId: matchedKey?.id,
includeDeployedState: true,
workflowName: workflowData!.name,
})
if (!deployResult.success) {
return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500)
}
const deployedAt = deployResult.deployedAt!
if (matchedKey) {
try {
await db
@@ -313,31 +256,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Track workflow deployment
try {
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
// Aggregate block types to understand which blocks are being used
const blockTypeCounts: Record<string, number> = {}
for (const block of Object.values(currentState.blocks)) {
const blockType = (block as any).type || 'unknown'
blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1
}
trackPlatformEvent('platform.workflow.deployed', {
'workflow.id': id,
'workflow.name': workflowData!.name,
'workflow.blocks_count': Object.keys(currentState.blocks).length,
'workflow.edges_count': currentState.edges.length,
'workflow.has_loops': Object.keys(currentState.loops).length > 0,
'workflow.has_parallels': Object.keys(currentState.parallels).length > 0,
'workflow.api_key_type': keyInfo?.type || 'default',
'workflow.block_types': JSON.stringify(blockTypeCounts),
})
} catch (_e) {
// Silently fail
}
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key'
return createSuccessResponse({

View File

@@ -1,4 +1,4 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
@@ -19,7 +19,11 @@ export async function POST(
const { id, version } = await params
try {
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
@@ -29,6 +33,52 @@ export async function POST(
return createErrorResponse('Invalid version', 400)
}
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {}
let pinnedApiKeyId: string | null = null
if (providedApiKey) {
const currentUserId = session?.user?.id
if (currentUserId) {
const [personalKey] = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.userId, currentUserId),
eq(apiKey.type, 'personal')
)
)
.limit(1)
if (personalKey) {
pinnedApiKeyId = personalKey.id
} else if (workflowData!.workspaceId) {
const [workspaceKey] = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.workspaceId, workflowData!.workspaceId),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (workspaceKey) {
pinnedApiKeyId = workspaceKey.id
}
}
}
}
const now = new Date()
await db.transaction(async (tx) => {
@@ -57,10 +107,16 @@ export async function POST(
throw new Error('Deployment version not found')
}
await tx
.update(workflow)
.set({ isDeployed: true, deployedAt: now })
.where(eq(workflow.id, id))
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt: now,
}
if (pinnedApiKeyId) {
updateData.pinnedApiKeyId = pinnedApiKeyId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})
return createSuccessResponse({ success: true, deployedAt: now })

View File

@@ -0,0 +1,469 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, Copy, Info, Loader2, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Input,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
const logger = createLogger('ApiKeySelector')
export interface ApiKey {
id: string
name: string
key: string
displayKey?: string
lastUsed?: string
createdAt: string
expiresAt?: string
createdBy?: string
}
interface ApiKeysData {
workspace: ApiKey[]
personal: ApiKey[]
}
interface ApiKeySelectorProps {
value: string
onChange: (keyId: string) => void
disabled?: boolean
apiKeys?: ApiKey[]
onApiKeyCreated?: () => void
showLabel?: boolean
label?: string
isDeployed?: boolean
deployedApiKeyDisplay?: string
}
export function ApiKeySelector({
value,
onChange,
disabled = false,
apiKeys = [],
onApiKeyCreated,
showLabel = true,
label = 'API Key',
isDeployed = false,
deployedApiKeyDisplay,
}: ApiKeySelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
const [apiKeysData, setApiKeysData] = useState<ApiKeysData | null>(null)
const [isCreatingKey, setIsCreatingKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
const [keysLoaded, setKeysLoaded] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [justCreatedKeyId, setJustCreatedKeyId] = useState<string | null>(null)
useEffect(() => {
fetchApiKeys()
}, [workspaceId])
const fetchApiKeys = async () => {
try {
setKeysLoaded(false)
const [workspaceRes, personalRes] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}/api-keys`),
fetch('/api/users/me/api-keys'),
])
const workspaceData = workspaceRes.ok ? await workspaceRes.json() : { keys: [] }
const personalData = personalRes.ok ? await personalRes.json() : { keys: [] }
setApiKeysData({
workspace: workspaceData.keys || [],
personal: personalData.keys || [],
})
setKeysLoaded(true)
} catch (error) {
logger.error('Error fetching API keys:', { error })
setKeysLoaded(true)
}
}
const handleCreateKey = async () => {
if (!newKeyName.trim()) {
setCreateError('Please enter a name for the API key')
return
}
try {
setIsSubmittingCreate(true)
setCreateError(null)
const endpoint =
keyType === 'workspace'
? `/api/workspaces/${workspaceId}/api-keys`
: '/api/users/me/api-keys'
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create API key')
}
const data = await response.json()
setNewKey(data.key)
setJustCreatedKeyId(data.key.id)
setShowNewKeyDialog(true)
setIsCreatingKey(false)
setNewKeyName('')
// Refresh API keys
await fetchApiKeys()
onApiKeyCreated?.()
} catch (error: any) {
setCreateError(error.message || 'Failed to create API key')
} finally {
setIsSubmittingCreate(false)
}
}
const handleCopyKey = async () => {
if (newKey?.key) {
await navigator.clipboard.writeText(newKey.key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
}
if (isDeployed && deployedApiKeyDisplay) {
return (
<div className='space-y-1.5'>
{showLabel && (
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>{label}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className='h-3.5 w-3.5 text-muted-foreground' />
</TooltipTrigger>
<TooltipContent>
<p>Owner is billed for usage</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<div className='rounded-md border bg-background'>
<div className='flex items-center justify-between p-3'>
<pre className='flex-1 overflow-x-auto whitespace-pre-wrap font-mono text-xs'>
{(() => {
const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
if (match) {
return match[1].trim()
}
return deployedApiKeyDisplay
})()}
</pre>
{(() => {
const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
if (match) {
const type = match[2]
return (
<div className='ml-2 flex-shrink-0'>
<span className='inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs capitalize'>
{type}
</span>
</div>
)
}
return null
})()}
</div>
</div>
</div>
)
}
return (
<>
<div className='space-y-2'>
{showLabel && (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>{label}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className='h-3.5 w-3.5 text-muted-foreground' />
</TooltipTrigger>
<TooltipContent>
<p>Key Owner is Billed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{!disabled && (
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 gap-1 px-2 text-muted-foreground text-xs'
onClick={() => {
setIsCreatingKey(true)
setCreateError(null)
}}
>
<Plus className='h-3.5 w-3.5' />
<span>Create new</span>
</Button>
)}
</div>
)}
<Select value={value} onValueChange={onChange} disabled={disabled || !keysLoaded}>
<SelectTrigger className={!keysLoaded ? 'opacity-70' : ''}>
{!keysLoaded ? (
<div className='flex items-center space-x-2'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Loading API keys...</span>
</div>
) : (
<SelectValue placeholder='Select an API key' className='text-sm' />
)}
</SelectTrigger>
<SelectContent align='start' className='w-[var(--radix-select-trigger-width)] py-1'>
{apiKeysData && apiKeysData.workspace.length > 0 && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Workspace
</SelectLabel>
{apiKeysData.workspace.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{((apiKeysData && apiKeysData.personal.length > 0) ||
(!apiKeysData && apiKeys.length > 0)) && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Personal
</SelectLabel>
{(apiKeysData ? apiKeysData.personal : apiKeys).map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{!apiKeysData && apiKeys.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div>
)}
{apiKeysData &&
apiKeysData.workspace.length === 0 &&
apiKeysData.personal.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div>
)}
</SelectContent>
</Select>
</div>
{/* Create Key Dialog */}
<AlertDialog open={isCreatingKey} onOpenChange={setIsCreatingKey}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-4 py-2'>
{canCreateWorkspaceKeys && (
<div className='space-y-2'>
<p className='font-[360] text-sm'>API Key Type</p>
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Workspace
</Button>
</div>
</div>
)}
<div className='space-y-2'>
<Label htmlFor='new-key-name'>API Key Name</Label>
<Input
id='new-key-name'
placeholder='My API Key'
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null)
}}
disabled={isSubmittingCreate}
/>
{createError && <p className='text-destructive text-sm'>{createError}</p>}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isSubmittingCreate}
onClick={() => {
setNewKeyName('')
setCreateError(null)
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isSubmittingCreate || !newKeyName.trim()}
onClick={(e) => {
e.preventDefault()
handleCreateKey()
}}
>
{isSubmittingCreate ? (
<>
<Loader2 className='mr-1.5 h-3 w-3 animate-spin' />
Creating...
</>
) : (
'Create'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* New Key Dialog */}
<AlertDialog open={showNewKeyDialog} onOpenChange={setShowNewKeyDialog}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>API Key Created Successfully</AlertDialogTitle>
<AlertDialogDescription>
Your new API key has been created. Make sure to copy it now as you won't be able to
see it again.
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-2 py-2'>
<Label htmlFor='created-key'>API Key</Label>
<div className='flex gap-2'>
<Input
id='created-key'
value={newKey?.key || ''}
readOnly
className='font-mono text-sm'
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={handleCopyKey}
className='flex-shrink-0'
>
{copySuccess ? <Check className='h-4 w-4' /> : <Copy className='h-4 w-4' />}
</Button>
</div>
</div>
<AlertDialogFooter>
<AlertDialogAction
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
setCopySuccess(false)
// Auto-select the newly created key
if (justCreatedKeyId) {
onChange(justCreatedKeyId)
setJustCreatedKeyId(null)
}
}}
>
Done
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -41,11 +41,12 @@ interface ChatDeployProps {
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
onPreDeployWorkflow?: () => Promise<void>
showDeleteConfirmation?: boolean
setShowDeleteConfirmation?: (show: boolean) => void
onDeploymentComplete?: () => void
onDeployed?: () => void
onUndeploy?: () => Promise<void>
onVersionActivated?: () => void
}
interface ExistingChat {
@@ -69,11 +70,12 @@ export function ChatDeploy({
chatSubmitting,
setChatSubmitting,
onValidationChange,
onPreDeployWorkflow,
showDeleteConfirmation: externalShowDeleteConfirmation,
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
onDeploymentComplete,
onDeployed,
onUndeploy,
onVersionActivated,
}: ChatDeployProps) {
const [isLoading, setIsLoading] = useState(false)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
@@ -97,6 +99,7 @@ export function ChatDeploy({
const { deployedUrl, deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const isFormValid =
isIdentifierValid &&
Boolean(formData.title.trim()) &&
@@ -148,7 +151,6 @@ export function ChatDeploy({
: [],
})
// Set image URL if it exists
if (chatDetail.customizations?.imageUrl) {
setImageUrl(chatDetail.customizations.imageUrl)
}
@@ -178,8 +180,6 @@ export function ChatDeploy({
setChatSubmitting(true)
try {
await onPreDeployWorkflow?.()
if (!validateForm()) {
setChatSubmitting(false)
return
@@ -191,14 +191,13 @@ export function ChatDeploy({
return
}
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id, imageUrl)
await deployChat(workflowId, formData, null, existingChat?.id, imageUrl)
onChatExistsChange?.(true)
setShowSuccessView(true)
onDeployed?.()
onVersionActivated?.()
// Fetch the updated chat data immediately after deployment
// This ensures existingChat is available when switching back to edit mode
await fetchExistingChat()
} catch (error: any) {
if (error.message?.includes('identifier')) {
@@ -226,13 +225,15 @@ export function ChatDeploy({
throw new Error(error.error || 'Failed to delete chat')
}
// Update state
if (onUndeploy) {
await onUndeploy()
}
setExistingChat(null)
setImageUrl(null)
setImageUploadError(null)
onChatExistsChange?.(false)
// Notify parent of successful deletion
onDeploymentComplete?.()
} catch (error: any) {
logger.error('Failed to delete chat:', error)
@@ -268,8 +269,8 @@ export function ChatDeploy({
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>
.
</span>{' '}
and undeploy the workflow.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>
@@ -324,6 +325,7 @@ export function ChatDeploy({
onValidationChange={setIsIdentifierValid}
isEditingExisting={!!existingChat}
/>
<div className='space-y-2'>
<Label htmlFor='title' className='font-medium text-sm'>
Chat Title
@@ -403,14 +405,13 @@ export function ChatDeploy({
</p>
</div>
{/* Image Upload Section */}
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Logo</Label>
<ImageUpload
value={imageUrl}
onUpload={(url) => {
setImageUrl(url)
setImageUploadError(null) // Clear error on successful upload
setImageUploadError(null)
}}
onError={setImageUploadError}
onUploadStart={setIsImageUploading}
@@ -427,7 +428,6 @@ export function ChatDeploy({
)}
</div>
{/* Hidden delete trigger button for modal footer */}
<button
type='button'
data-delete-trigger
@@ -437,7 +437,6 @@ export function ChatDeploy({
</div>
</form>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -446,8 +445,8 @@ export function ChatDeploy({
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>
.
</span>{' '}
and undeploy the workflow.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>

View File

@@ -72,7 +72,6 @@ export function useChatDeployment() {
})
.filter(Boolean) as OutputConfig[]
// Create request payload
const payload = {
workflowId,
identifier: formData.identifier.trim(),
@@ -89,7 +88,7 @@ export function useChatDeployment() {
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey: deploymentInfo?.apiKey,
deployApiEnabled: !existingChatId, // Only deploy API for new chats
deployApiEnabled: !existingChatId,
}
// Validate with Zod

View File

@@ -1,61 +1,18 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Check, Copy, Loader2, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
type ApiKey,
ApiKeySelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector'
const logger = createLogger('DeployForm')
interface ApiKey {
id: string
name: string
key: string
displayKey?: string
lastUsed?: string
createdAt: string
expiresAt?: string
createdBy?: string
}
interface ApiKeysData {
workspace: ApiKey[]
personal: ApiKey[]
conflicts: string[]
}
// Form schema for API key selection or creation
const deployFormSchema = z.object({
apiKey: z.string().min(1, 'Please select an API key'),
@@ -65,213 +22,39 @@ const deployFormSchema = z.object({
type DeployFormValues = z.infer<typeof deployFormSchema>
interface DeployFormProps {
apiKeys: ApiKey[] // Legacy prop for backward compatibility
keysLoaded: boolean
apiKeys: ApiKey[]
selectedApiKeyId: string
onApiKeyChange: (keyId: string) => void
onSubmit: (data: DeployFormValues) => void
onApiKeyCreated?: () => void
// Optional id to bind an external submit button via the `form` attribute
formId?: string
isDeployed?: boolean
deployedApiKeyDisplay?: string
}
export function DeployForm({
apiKeys,
keysLoaded,
selectedApiKeyId,
onApiKeyChange,
onSubmit,
onApiKeyCreated,
formId,
isDeployed = false,
deployedApiKeyDisplay,
}: DeployFormProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
// State
const [apiKeysData, setApiKeysData] = useState<ApiKeysData | null>(null)
const [isCreatingKey, setIsCreatingKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
const [keysLoaded2, setKeysLoaded2] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [justCreatedKeyId, setJustCreatedKeyId] = useState<string | null>(null)
// Get all available API keys (workspace + personal)
const allApiKeys = apiKeysData ? [...apiKeysData.workspace, ...apiKeysData.personal] : apiKeys
// Initialize form with react-hook-form
const form = useForm<DeployFormValues>({
resolver: zodResolver(deployFormSchema),
defaultValues: {
apiKey: allApiKeys.length > 0 ? allApiKeys[0].id : '',
apiKey: selectedApiKeyId || (apiKeys.length > 0 ? apiKeys[0].id : ''),
newKeyName: '',
},
})
// Fetch workspace and personal API keys
const fetchApiKeys = async () => {
if (!workspaceId) return
try {
setKeysLoaded2(false)
const [workspaceResponse, personalResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}/api-keys`),
fetch('/api/users/me/api-keys'),
])
let workspaceKeys: ApiKey[] = []
let personalKeys: ApiKey[] = []
if (workspaceResponse.ok) {
const workspaceData = await workspaceResponse.json()
workspaceKeys = workspaceData.keys || []
} else {
logger.error('Error fetching workspace API keys:', { status: workspaceResponse.status })
}
if (personalResponse.ok) {
const personalData = await personalResponse.json()
personalKeys = personalData.keys || []
} else {
logger.error('Error fetching personal API keys:', { status: personalResponse.status })
}
// Client-side conflict detection
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
const conflicts = personalKeys
.filter((key) => workspaceKeyNames.has(key.name))
.map((key) => key.name)
setApiKeysData({
workspace: workspaceKeys,
personal: personalKeys,
conflicts,
})
setKeysLoaded2(true)
} catch (error) {
logger.error('Error fetching API keys:', { error })
setKeysLoaded2(true)
}
}
// Update on dependency changes beyond the initial load
useEffect(() => {
if (workspaceId) {
fetchApiKeys()
if (selectedApiKeyId) {
form.setValue('apiKey', selectedApiKeyId)
}
}, [workspaceId])
useEffect(() => {
if ((keysLoaded || keysLoaded2) && allApiKeys.length > 0) {
const currentValue = form.getValues().apiKey
// If we just created a key, prioritize selecting it
if (justCreatedKeyId && allApiKeys.find((key) => key.id === justCreatedKeyId)) {
form.setValue('apiKey', justCreatedKeyId)
setJustCreatedKeyId(null) // Clear after setting
}
// Otherwise, ensure form has a value if it doesn't already
else if (!currentValue || !allApiKeys.find((key) => key.id === currentValue)) {
form.setValue('apiKey', allApiKeys[0].id)
}
}
}, [keysLoaded, keysLoaded2, allApiKeys, form, justCreatedKeyId])
// Generate a new API key
const handleCreateKey = async () => {
if (!newKeyName.trim()) return
// Client-side duplicate check for immediate feedback
const trimmedName = newKeyName.trim()
const isDuplicate =
keyType === 'workspace'
? (apiKeysData?.workspace || []).some((k) => k.name === trimmedName)
: (apiKeysData?.personal || apiKeys || []).some((k) => k.name === trimmedName)
if (isDuplicate) {
setCreateError(
keyType === 'workspace'
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`
)
return
}
setIsSubmittingCreate(true)
setCreateError(null)
try {
const url =
keyType === 'workspace'
? `/api/workspaces/${workspaceId}/api-keys`
: '/api/users/me/api-keys'
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newKeyName.trim(),
}),
})
if (response.ok) {
const data = await response.json()
// Show the new key dialog with the API key (only shown once)
setNewKey(data.key)
setShowNewKeyDialog(true)
// Reset form and close the create dialog ONLY on success
setNewKeyName('')
setKeyType('personal')
setCreateError(null)
setIsCreatingKey(false)
// Store the newly created key ID for auto-selection
setJustCreatedKeyId(data.key.id)
// Refresh the keys list - the useEffect will handle auto-selection
await fetchApiKeys()
// Trigger a refresh of the keys list in the parent component
if (onApiKeyCreated) {
onApiKeyCreated()
}
} else {
let errorData
try {
errorData = await response.json()
} catch (parseError) {
errorData = { error: 'Server error' }
}
// Check for duplicate name error and prefer server message
const serverMessage = typeof errorData?.error === 'string' ? errorData.error : null
if (response.status === 409 || serverMessage?.toLowerCase().includes('already exists')) {
setCreateError(
serverMessage ||
(keyType === 'workspace'
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`)
)
} else {
setCreateError(errorData.error || 'Failed to create API key. Please try again.')
}
logger.error('Failed to create API key:', errorData)
}
} catch (error) {
setCreateError('Failed to create API key. Please check your connection and try again.')
logger.error('Error creating API key:', { error })
} finally {
setIsSubmittingCreate(false)
}
}
// Copy API key to clipboard
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
}, [selectedApiKeyId, form])
return (
<Form {...form}>
@@ -283,251 +66,28 @@ export function DeployForm({
}}
className='space-y-6'
>
{/* API Key selection */}
<FormField
control={form.control}
name='apiKey'
render={({ field }) => (
<FormItem className='space-y-1.5'>
<div className='flex items-center justify-between'>
<FormLabel className='font-medium text-sm'>Select API Key</FormLabel>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 gap-1 px-2 text-muted-foreground text-xs'
onClick={() => {
setIsCreatingKey(true)
setCreateError(null)
}}
>
<Plus className='h-3.5 w-3.5' />
<span>Create new</span>
</Button>
</div>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className={!keysLoaded ? 'opacity-70' : ''}>
{!keysLoaded ? (
<div className='flex items-center space-x-2'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Loading API keys...</span>
</div>
) : (
<SelectValue placeholder='Select an API key' className='text-sm' />
)}
</SelectTrigger>
</FormControl>
<SelectContent align='start' className='w-[var(--radix-select-trigger-width)] py-1'>
{apiKeysData && apiKeysData.workspace.length > 0 && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Workspace
</SelectLabel>
{apiKeysData.workspace.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{((apiKeysData && apiKeysData.personal.length > 0) ||
(!apiKeysData && apiKeys.length > 0)) && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Personal
</SelectLabel>
{(apiKeysData ? apiKeysData.personal : apiKeys).map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{!apiKeysData && apiKeys.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>
No API keys available
</div>
)}
{apiKeysData &&
apiKeysData.workspace.length === 0 &&
apiKeysData.personal.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>
No API keys available
</div>
)}
</SelectContent>
</Select>
<ApiKeySelector
value={field.value}
onChange={(keyId) => {
field.onChange(keyId)
onApiKeyChange(keyId)
}}
apiKeys={apiKeys}
onApiKeyCreated={onApiKeyCreated}
showLabel={true}
label='Select API Key'
isDeployed={isDeployed}
deployedApiKeyDisplay={deployedApiKeyDisplay}
/>
<FormMessage />
</FormItem>
)}
/>
{/* Create API Key Dialog */}
<AlertDialog open={isCreatingKey} onOpenChange={setIsCreatingKey}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-4 py-2'>
{canCreateWorkspaceKeys && (
<div className='space-y-2'>
<p className='font-[360] text-sm'>API Key Type</p>
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Workspace
</Button>
</div>
</div>
)}
<div className='space-y-2'>
<p className='font-[360] text-sm'>
Enter a name for your API key to help you identify it later.
</p>
<Input
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null) // Clear error when user types
}}
placeholder='e.g., Development, Production'
className='h-9 rounded-[8px]'
autoFocus
/>
{createError && <div className='text-red-600 text-sm'>{createError}</div>}
</div>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px] border-border bg-background text-foreground hover:bg-muted dark:border-border dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
onClick={() => {
setNewKeyName('')
setKeyType('personal')
setCreateError(null)
}}
>
Cancel
</AlertDialogCancel>
<Button
type='button'
onClick={handleCreateKey}
className='h-9 w-full rounded-[8px] bg-primary text-white hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50'
disabled={
!newKeyName.trim() ||
isSubmittingCreate ||
(keyType === 'workspace' && !canCreateWorkspaceKeys)
}
>
{isSubmittingCreate ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Creating...
</>
) : (
`Create ${keyType === 'workspace' ? 'Workspace' : 'Personal'} Key`
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* New API Key Dialog */}
<AlertDialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
<AlertDialogDescription>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</AlertDialogDescription>
</AlertDialogHeader>
{newKey && (
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
</AlertDialogContent>
</AlertDialog>
</form>
</Form>
)

View File

@@ -118,9 +118,15 @@ export function DeployedWorkflowModal({
Active
</div>
) : (
<Button onClick={onActivateVersion} disabled={!!isActivating}>
{isActivating ? 'Activating…' : 'Activate'}
</Button>
<div className='flex items-center gap-0'>
<Button
variant='outline'
disabled={!!isActivating}
onClick={() => onActivateVersion?.()}
>
{isActivating ? 'Activating…' : 'Activate'}
</Button>
</div>
))}
</div>

View File

@@ -35,6 +35,7 @@ export function DeploymentControls({
const isDeployed = deploymentStatus?.isDeployed || false
const workflowNeedsRedeployment = needsRedeployment
const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment
const [isDeploying, _setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -93,7 +94,9 @@ export function DeploymentControls({
'h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs',
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
'transition-all duration-200',
isDeployed && 'text-[var(--brand-primary-hover-hex)]',
isDeployed && !isPreviousVersionActive && 'text-[var(--brand-primary-hover-hex)]',
isPreviousVersionActive &&
'border-purple-500 bg-purple-500/10 text-purple-600 dark:text-purple-400',
isDisabled &&
'cursor-not-allowed opacity-50 hover:border hover:bg-card hover:text-card-foreground hover:shadow-xs'
)}

View File

@@ -100,17 +100,17 @@ export const QdrantBlock: BlockConfig<QdrantResponse> = {
condition: { field: 'operation', value: 'search' },
},
{
id: 'with_payload',
title: 'With Payload',
type: 'switch',
layout: 'full',
condition: { field: 'operation', value: 'search' },
},
{
id: 'with_vector',
title: 'With Vector',
type: 'switch',
id: 'search_return_data',
title: 'Return Data',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Payload Only', id: 'payload_only' },
{ label: 'Vector Only', id: 'vector_only' },
{ label: 'Both Payload and Vector', id: 'both' },
{ label: 'None (IDs and scores only)', id: 'none' },
],
value: () => 'payload_only',
condition: { field: 'operation', value: 'search' },
},
// Fetch fields
@@ -142,17 +142,17 @@ export const QdrantBlock: BlockConfig<QdrantResponse> = {
required: true,
},
{
id: 'with_payload',
title: 'With Payload',
type: 'switch',
layout: 'full',
condition: { field: 'operation', value: 'fetch' },
},
{
id: 'with_vector',
title: 'With Vector',
type: 'switch',
id: 'fetch_return_data',
title: 'Return Data',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Payload Only', id: 'payload_only' },
{ label: 'Vector Only', id: 'vector_only' },
{ label: 'Both Payload and Vector', id: 'both' },
{ label: 'None (IDs only)', id: 'none' },
],
value: () => 'payload_only',
condition: { field: 'operation', value: 'fetch' },
},
{
@@ -194,6 +194,8 @@ export const QdrantBlock: BlockConfig<QdrantResponse> = {
limit: { type: 'number', description: 'Result limit' },
filter: { type: 'json', description: 'Search filter' },
ids: { type: 'json', description: 'Point identifiers' },
search_return_data: { type: 'string', description: 'Data to return from search' },
fetch_return_data: { type: 'string', description: 'Data to return from fetch' },
with_payload: { type: 'boolean', description: 'Include payload' },
with_vector: { type: 'boolean', description: 'Include vectors' },
},

View File

@@ -1,13 +1,15 @@
import {
db,
workflow,
workflowBlocks,
workflowDeploymentVersion,
workflowEdges,
workflowSubflows,
} from '@sim/db'
import type { InferSelectModel } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, sql } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -356,3 +358,131 @@ export async function migrateWorkflowToNormalizedTables(
}
}
}
/**
* Deploy a workflow by creating a new deployment version
*/
export async function deployWorkflow(params: {
workflowId: string
deployedBy: string // User ID of the person deploying
pinnedApiKeyId?: string
includeDeployedState?: boolean
workflowName?: string
}): Promise<{
success: boolean
version?: number
deployedAt?: Date
currentState?: any
error?: string
}> {
const {
workflowId,
deployedBy,
pinnedApiKeyId,
includeDeployedState = false,
workflowName,
} = params
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return { success: false, error: 'Failed to load workflow state' }
}
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
}
const now = new Date()
const deployedVersion = await db.transaction(async (tx) => {
// Get next version number
const [{ maxVersion }] = await tx
.select({ maxVersion: sql`COALESCE(MAX("version"), 0)` })
.from(workflowDeploymentVersion)
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
const nextVersion = Number(maxVersion) + 1
// Deactivate all existing versions
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
// Create new deployment version
await tx.insert(workflowDeploymentVersion).values({
id: uuidv4(),
workflowId,
version: nextVersion,
state: currentState,
isActive: true,
createdBy: deployedBy,
createdAt: now,
})
// Update workflow to deployed
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt: now,
}
if (includeDeployedState) {
updateData.deployedState = currentState
}
if (pinnedApiKeyId) {
updateData.pinnedApiKeyId = pinnedApiKeyId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
return nextVersion
})
logger.info(`Deployed workflow ${workflowId} as v${deployedVersion}`)
// Track deployment telemetry if workflow name is provided
if (workflowName) {
try {
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
const blockTypeCounts: Record<string, number> = {}
for (const block of Object.values(currentState.blocks)) {
const blockType = (block as any).type || 'unknown'
blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1
}
trackPlatformEvent('platform.workflow.deployed', {
'workflow.id': workflowId,
'workflow.name': workflowName,
'workflow.blocks_count': Object.keys(currentState.blocks).length,
'workflow.edges_count': currentState.edges.length,
'workflow.loops_count': Object.keys(currentState.loops).length,
'workflow.parallels_count': Object.keys(currentState.parallels).length,
'workflow.block_types': JSON.stringify(blockTypeCounts),
'deployment.version': deployedVersion,
})
} catch (telemetryError) {
logger.warn(`Failed to track deployment telemetry for ${workflowId}`, telemetryError)
}
}
return {
success: true,
version: deployedVersion,
deployedAt: now,
currentState,
}
} catch (error) {
logger.error(`Error deploying workflow ${workflowId}:`, error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}

View File

@@ -16,7 +16,13 @@ export interface StreamingConfig {
export interface StreamingResponseOptions {
requestId: string
workflow: { id: string; userId: string; workspaceId?: string | null; isDeployed?: boolean }
workflow: {
id: string
userId: string
workspaceId?: string | null
isDeployed?: boolean
variables?: Record<string, any>
}
input: any
executingUserId: string
streamConfig: StreamingConfig

View File

@@ -32,6 +32,12 @@ export const fetchPointsTool: ToolConfig<QdrantFetchParams, QdrantResponse> = {
visibility: 'user-only',
description: 'Array of point IDs to fetch',
},
fetch_return_data: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Data to return from fetch',
},
with_payload: {
type: 'boolean',
required: false,
@@ -53,11 +59,38 @@ export const fetchPointsTool: ToolConfig<QdrantFetchParams, QdrantResponse> = {
'Content-Type': 'application/json',
...(params.apiKey ? { 'api-key': params.apiKey } : {}),
}),
body: (params) => ({
ids: params.ids,
with_payload: params.with_payload,
with_vector: params.with_vector,
}),
body: (params) => {
// Calculate with_payload and with_vector from fetch_return_data if provided
let withPayload = params.with_payload ?? false
let withVector = params.with_vector ?? false
if (params.fetch_return_data) {
switch (params.fetch_return_data) {
case 'payload_only':
withPayload = true
withVector = false
break
case 'vector_only':
withPayload = false
withVector = true
break
case 'both':
withPayload = true
withVector = true
break
case 'none':
withPayload = false
withVector = false
break
}
}
return {
ids: params.ids,
with_payload: withPayload,
with_vector: withVector,
}
},
},
transformResponse: async (response) => {

View File

@@ -44,6 +44,12 @@ export const searchVectorTool: ToolConfig<QdrantSearchParams, QdrantResponse> =
visibility: 'user-only',
description: 'Filter to apply to the search',
},
search_return_data: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Data to return from search',
},
with_payload: {
type: 'boolean',
required: false,
@@ -66,13 +72,40 @@ export const searchVectorTool: ToolConfig<QdrantSearchParams, QdrantResponse> =
'Content-Type': 'application/json',
...(params.apiKey ? { 'api-key': params.apiKey } : {}),
}),
body: (params) => ({
query: params.vector,
limit: params.limit ? Number.parseInt(params.limit.toString()) : 10,
filter: params.filter,
with_payload: params.with_payload,
with_vector: params.with_vector,
}),
body: (params) => {
// Calculate with_payload and with_vector from search_return_data if provided
let withPayload = params.with_payload ?? false
let withVector = params.with_vector ?? false
if (params.search_return_data) {
switch (params.search_return_data) {
case 'payload_only':
withPayload = true
withVector = false
break
case 'vector_only':
withPayload = false
withVector = true
break
case 'both':
withPayload = true
withVector = true
break
case 'none':
withPayload = false
withVector = false
break
}
}
return {
query: params.vector,
limit: params.limit ? Number.parseInt(params.limit.toString()) : 10,
filter: params.filter,
with_payload: withPayload,
with_vector: withVector,
}
},
},
transformResponse: async (response) => {

View File

@@ -20,12 +20,14 @@ export interface QdrantSearchParams extends QdrantBaseParams {
vector: number[]
limit?: number
filter?: Record<string, any>
search_return_data?: string
with_payload?: boolean
with_vector?: boolean
}
export interface QdrantFetchParams extends QdrantBaseParams {
ids: string[]
fetch_return_data?: string
with_payload?: boolean
with_vector?: boolean
}