mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
ce4893a53c
commit
ad7b791242
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user