mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-17 02:48:02 -05:00
Compare commits
12 Commits
fix/start-
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5de7228dd9 | ||
|
|
75898c69ed | ||
|
|
b14672887b | ||
|
|
d024c1e489 | ||
|
|
d75ea37b3c | ||
|
|
fd23220cc3 | ||
|
|
a8d81097fc | ||
|
|
3768c6379c | ||
|
|
aa80116b99 | ||
|
|
78e4ca9d45 | ||
|
|
ce3ddb6ba0 | ||
|
|
8361931cdf |
@@ -9,12 +9,12 @@
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simdotai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
||||
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
||||
</p>
|
||||
|
||||
### Build Workflows with Ease
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["index", "basics", "api", "form", "logging", "costs"]
|
||||
"pages": ["index", "basics", "api", "logging", "costs"]
|
||||
}
|
||||
|
||||
@@ -36,43 +36,47 @@ Connect Google Vault to create exports, list exports, and manage holds within ma
|
||||
|
||||
### `google_vault_create_matters_export`
|
||||
|
||||
Create an export in a matter
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `matterId` | string | Yes | The matter ID |
|
||||
| `exportName` | string | Yes | Name for the export \(avoid special characters\) |
|
||||
| `corpus` | string | Yes | Data corpus to export \(MAIL, DRIVE, GROUPS, HANGOUTS_CHAT, VOICE\) |
|
||||
| `accountEmails` | string | No | Comma-separated list of user emails to scope export |
|
||||
| `orgUnitId` | string | No | Organization unit ID to scope export \(alternative to emails\) |
|
||||
| `startTime` | string | No | Start time for date filtering \(ISO 8601 format, e.g., 2024-01-01T00:00:00Z\) |
|
||||
| `endTime` | string | No | End time for date filtering \(ISO 8601 format, e.g., 2024-12-31T23:59:59Z\) |
|
||||
| `terms` | string | No | Search query terms to filter exported content |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `matters` | json | Array of matter objects \(for list_matters\) |
|
||||
| `exports` | json | Array of export objects \(for list_matters_export\) |
|
||||
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
|
||||
| `matter` | json | Created matter object \(for create_matters\) |
|
||||
| `export` | json | Created export object \(for create_matters_export\) |
|
||||
| `hold` | json | Created hold object \(for create_matters_holds\) |
|
||||
| `file` | json | Downloaded export file \(UserFile\) from execution files |
|
||||
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
|
||||
| `export` | json | Created export object |
|
||||
|
||||
### `google_vault_list_matters_export`
|
||||
|
||||
List exports for a matter
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `matterId` | string | Yes | The matter ID |
|
||||
| `pageSize` | number | No | Number of exports to return per page |
|
||||
| `pageToken` | string | No | Token for pagination |
|
||||
| `exportId` | string | No | Optional export ID to fetch a specific export |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `matters` | json | Array of matter objects \(for list_matters\) |
|
||||
| `exports` | json | Array of export objects \(for list_matters_export\) |
|
||||
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
|
||||
| `matter` | json | Created matter object \(for create_matters\) |
|
||||
| `export` | json | Created export object \(for create_matters_export\) |
|
||||
| `hold` | json | Created hold object \(for create_matters_holds\) |
|
||||
| `file` | json | Downloaded export file \(UserFile\) from execution files |
|
||||
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
|
||||
| `exports` | json | Array of export objects |
|
||||
| `export` | json | Single export object \(when exportId is provided\) |
|
||||
| `nextPageToken` | string | Token for fetching next page of results |
|
||||
|
||||
### `google_vault_download_export_file`
|
||||
|
||||
@@ -82,10 +86,10 @@ Download a single file from a Google Vault export (GCS object)
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `matterId` | string | Yes | No description |
|
||||
| `bucketName` | string | Yes | No description |
|
||||
| `objectName` | string | Yes | No description |
|
||||
| `fileName` | string | No | No description |
|
||||
| `matterId` | string | Yes | The matter ID |
|
||||
| `bucketName` | string | Yes | GCS bucket name from cloudStorageSink.files.bucketName |
|
||||
| `objectName` | string | Yes | GCS object name from cloudStorageSink.files.objectName |
|
||||
| `fileName` | string | No | Optional filename override for the downloaded file |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -95,82 +99,84 @@ Download a single file from a Google Vault export (GCS object)
|
||||
|
||||
### `google_vault_create_matters_holds`
|
||||
|
||||
Create a hold in a matter
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `matterId` | string | Yes | The matter ID |
|
||||
| `holdName` | string | Yes | Name for the hold |
|
||||
| `corpus` | string | Yes | Data corpus to hold \(MAIL, DRIVE, GROUPS, HANGOUTS_CHAT, VOICE\) |
|
||||
| `accountEmails` | string | No | Comma-separated list of user emails to put on hold |
|
||||
| `orgUnitId` | string | No | Organization unit ID to put on hold \(alternative to accounts\) |
|
||||
| `terms` | string | No | Search terms to filter held content \(for MAIL and GROUPS corpus\) |
|
||||
| `startTime` | string | No | Start time for date filtering \(ISO 8601 format, for MAIL and GROUPS corpus\) |
|
||||
| `endTime` | string | No | End time for date filtering \(ISO 8601 format, for MAIL and GROUPS corpus\) |
|
||||
| `includeSharedDrives` | boolean | No | Include files in shared drives \(for DRIVE corpus\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `matters` | json | Array of matter objects \(for list_matters\) |
|
||||
| `exports` | json | Array of export objects \(for list_matters_export\) |
|
||||
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
|
||||
| `matter` | json | Created matter object \(for create_matters\) |
|
||||
| `export` | json | Created export object \(for create_matters_export\) |
|
||||
| `hold` | json | Created hold object \(for create_matters_holds\) |
|
||||
| `file` | json | Downloaded export file \(UserFile\) from execution files |
|
||||
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
|
||||
| `hold` | json | Created hold object |
|
||||
|
||||
### `google_vault_list_matters_holds`
|
||||
|
||||
List holds for a matter
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `matterId` | string | Yes | The matter ID |
|
||||
| `pageSize` | number | No | Number of holds to return per page |
|
||||
| `pageToken` | string | No | Token for pagination |
|
||||
| `holdId` | string | No | Optional hold ID to fetch a specific hold |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `matters` | json | Array of matter objects \(for list_matters\) |
|
||||
| `exports` | json | Array of export objects \(for list_matters_export\) |
|
||||
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
|
||||
| `matter` | json | Created matter object \(for create_matters\) |
|
||||
| `export` | json | Created export object \(for create_matters_export\) |
|
||||
| `hold` | json | Created hold object \(for create_matters_holds\) |
|
||||
| `file` | json | Downloaded export file \(UserFile\) from execution files |
|
||||
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
|
||||
| `holds` | json | Array of hold objects |
|
||||
| `hold` | json | Single hold object \(when holdId is provided\) |
|
||||
| `nextPageToken` | string | Token for fetching next page of results |
|
||||
|
||||
### `google_vault_create_matters`
|
||||
|
||||
Create a new matter in Google Vault
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `name` | string | Yes | Name for the new matter |
|
||||
| `description` | string | No | Optional description for the matter |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `matters` | json | Array of matter objects \(for list_matters\) |
|
||||
| `exports` | json | Array of export objects \(for list_matters_export\) |
|
||||
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
|
||||
| `matter` | json | Created matter object \(for create_matters\) |
|
||||
| `export` | json | Created export object \(for create_matters_export\) |
|
||||
| `hold` | json | Created hold object \(for create_matters_holds\) |
|
||||
| `file` | json | Downloaded export file \(UserFile\) from execution files |
|
||||
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
|
||||
| `matter` | json | Created matter object |
|
||||
|
||||
### `google_vault_list_matters`
|
||||
|
||||
List matters, or get a specific matter if matterId is provided
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `pageSize` | number | No | Number of matters to return per page |
|
||||
| `pageToken` | string | No | Token for pagination |
|
||||
| `matterId` | string | No | Optional matter ID to fetch a specific matter |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `matters` | json | Array of matter objects \(for list_matters\) |
|
||||
| `exports` | json | Array of export objects \(for list_matters_export\) |
|
||||
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
|
||||
| `matter` | json | Created matter object \(for create_matters\) |
|
||||
| `export` | json | Created export object \(for create_matters_export\) |
|
||||
| `hold` | json | Created hold object \(for create_matters_holds\) |
|
||||
| `file` | json | Downloaded export file \(UserFile\) from execution files |
|
||||
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
|
||||
| `matters` | json | Array of matter objects |
|
||||
| `matter` | json | Single matter object \(when matterId is provided\) |
|
||||
| `nextPageToken` | string | Token for fetching next page of results |
|
||||
|
||||
|
||||
|
||||
@@ -11,10 +11,8 @@
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"content/docs/execution/index.mdx",
|
||||
"content/docs/connections/index.mdx",
|
||||
".next/dev/types/**/*.ts"
|
||||
"content/docs/connections/index.mdx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { generateChatTitle } from '@/lib/copilot/chat-title'
|
||||
import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
|
||||
import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -40,34 +41,8 @@ const ChatMessageSchema = z.object({
|
||||
userMessageId: z.string().optional(), // ID from frontend for the user message
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
model: z
|
||||
.enum([
|
||||
'gpt-5-fast',
|
||||
'gpt-5',
|
||||
'gpt-5-medium',
|
||||
'gpt-5-high',
|
||||
'gpt-5.1-fast',
|
||||
'gpt-5.1',
|
||||
'gpt-5.1-medium',
|
||||
'gpt-5.1-high',
|
||||
'gpt-5-codex',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.2-pro',
|
||||
'gpt-4o',
|
||||
'gpt-4.1',
|
||||
'o3',
|
||||
'claude-4-sonnet',
|
||||
'claude-4.5-haiku',
|
||||
'claude-4.5-sonnet',
|
||||
'claude-4.5-opus',
|
||||
'claude-4.1-opus',
|
||||
'gemini-3-pro',
|
||||
])
|
||||
.optional()
|
||||
.default('claude-4.5-opus'),
|
||||
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'),
|
||||
model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.5-opus'),
|
||||
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
|
||||
prefetch: z.boolean().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(true),
|
||||
@@ -295,7 +270,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const defaults = getCopilotModel('chat')
|
||||
const modelToUse = env.COPILOT_MODEL || defaults.model
|
||||
const selectedModel = model || defaults.model
|
||||
const envModel = env.COPILOT_MODEL || defaults.model
|
||||
|
||||
let providerConfig: CopilotProviderConfig | undefined
|
||||
const providerEnv = env.COPILOT_PROVIDER as any
|
||||
@@ -304,7 +280,7 @@ export async function POST(req: NextRequest) {
|
||||
if (providerEnv === 'azure-openai') {
|
||||
providerConfig = {
|
||||
provider: 'azure-openai',
|
||||
model: modelToUse,
|
||||
model: envModel,
|
||||
apiKey: env.AZURE_OPENAI_API_KEY,
|
||||
apiVersion: 'preview',
|
||||
endpoint: env.AZURE_OPENAI_ENDPOINT,
|
||||
@@ -312,7 +288,7 @@ export async function POST(req: NextRequest) {
|
||||
} else if (providerEnv === 'vertex') {
|
||||
providerConfig = {
|
||||
provider: 'vertex',
|
||||
model: modelToUse,
|
||||
model: envModel,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
vertexProject: env.VERTEX_PROJECT,
|
||||
vertexLocation: env.VERTEX_LOCATION,
|
||||
@@ -320,12 +296,15 @@ export async function POST(req: NextRequest) {
|
||||
} else {
|
||||
providerConfig = {
|
||||
provider: providerEnv,
|
||||
model: modelToUse,
|
||||
model: selectedModel,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
|
||||
|
||||
// Determine conversationId to use for this request
|
||||
const effectiveConversationId =
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
@@ -345,7 +324,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
} | null = null
|
||||
|
||||
if (mode === 'agent') {
|
||||
if (effectiveMode === 'build') {
|
||||
// Build base tools (executed locally, not deferred)
|
||||
// Include function_execute for code execution capability
|
||||
baseTools = [
|
||||
@@ -452,8 +431,8 @@ export async function POST(req: NextRequest) {
|
||||
userId: authenticatedUserId,
|
||||
stream: stream,
|
||||
streamToolCalls: true,
|
||||
model: model,
|
||||
mode: mode,
|
||||
model: selectedModel,
|
||||
mode: transportMode,
|
||||
messageId: userMessageIdToUse,
|
||||
version: SIM_AGENT_VERSION,
|
||||
...(providerConfig ? { provider: providerConfig } : {}),
|
||||
@@ -477,7 +456,7 @@ export async function POST(req: NextRequest) {
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
hasFileAttachments: processedFileContents.length > 0,
|
||||
messageLength: message.length,
|
||||
mode,
|
||||
mode: effectiveMode,
|
||||
hasTools: integrationTools.length > 0,
|
||||
toolCount: integrationTools.length,
|
||||
hasBaseTools: baseTools.length > 0,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { COPILOT_MODES } from '@/lib/copilot/models'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createInternalServerErrorResponse,
|
||||
@@ -45,7 +46,7 @@ const UpdateMessagesSchema = z.object({
|
||||
planArtifact: z.string().nullable().optional(),
|
||||
config: z
|
||||
.object({
|
||||
mode: z.enum(['ask', 'build', 'plan']).optional(),
|
||||
mode: z.enum(COPILOT_MODES).optional(),
|
||||
model: z.string().optional(),
|
||||
})
|
||||
.nullable()
|
||||
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getTool, resolveToolId } from '@/tools/utils'
|
||||
|
||||
@@ -28,45 +27,6 @@ const ExecuteToolSchema = z.object({
|
||||
workflowId: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolves all {{ENV_VAR}} references in a value recursively
|
||||
* Works with strings, arrays, and objects
|
||||
*/
|
||||
function resolveEnvVarReferences(value: any, envVars: Record<string, string>): any {
|
||||
if (typeof value === 'string') {
|
||||
// Check for exact match: entire string is "{{VAR_NAME}}"
|
||||
const exactMatchPattern = new RegExp(
|
||||
`^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$`
|
||||
)
|
||||
const exactMatch = exactMatchPattern.exec(value)
|
||||
if (exactMatch) {
|
||||
const envVarName = exactMatch[1].trim()
|
||||
return envVars[envVarName] ?? value
|
||||
}
|
||||
|
||||
// Check for embedded references: "prefix {{VAR}} suffix"
|
||||
const envVarPattern = createEnvVarPattern()
|
||||
return value.replace(envVarPattern, (match, varName) => {
|
||||
const trimmedName = varName.trim()
|
||||
return envVars[trimmedName] ?? match
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolveEnvVarReferences(item, envVars))
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const resolved: Record<string, any> = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
resolved[key] = resolveEnvVarReferences(val, envVars)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
@@ -145,7 +105,17 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Build execution params starting with LLM-provided arguments
|
||||
// Resolve all {{ENV_VAR}} references in the arguments
|
||||
const executionParams: Record<string, any> = resolveEnvVarReferences(toolArgs, decryptedEnvVars)
|
||||
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
||||
toolArgs,
|
||||
decryptedEnvVars,
|
||||
{
|
||||
resolveExactMatch: true,
|
||||
allowEmbedded: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: true,
|
||||
}
|
||||
) as Record<string, any>
|
||||
|
||||
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
||||
toolName,
|
||||
|
||||
@@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { CopilotModelId } from '@/lib/copilot/models'
|
||||
import { db } from '@/../../packages/db'
|
||||
import { settings } from '@/../../packages/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotUserModelsAPI')
|
||||
|
||||
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
const DEFAULT_ENABLED_MODELS: Record<CopilotModelId, boolean> = {
|
||||
'gpt-4o': false,
|
||||
'gpt-4.1': false,
|
||||
'gpt-5-fast': false,
|
||||
@@ -28,7 +29,7 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'claude-4.5-haiku': true,
|
||||
'claude-4.5-sonnet': true,
|
||||
'claude-4.5-opus': true,
|
||||
// 'claude-4.1-opus': true,
|
||||
'claude-4.1-opus': false,
|
||||
'gemini-3-pro': true,
|
||||
}
|
||||
|
||||
@@ -54,7 +55,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
|
||||
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
|
||||
mergedModels[modelId] = enabled
|
||||
if (modelId in mergedModels) {
|
||||
mergedModels[modelId as CopilotModelId] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -35,10 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
||||
|
||||
const startBlock = blocks.find(
|
||||
(block) =>
|
||||
block.type === 'starter' || block.type === 'start_trigger' || block.type === 'input_trigger'
|
||||
)
|
||||
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
|
||||
|
||||
if (!startBlock) {
|
||||
return []
|
||||
|
||||
@@ -9,6 +9,7 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
resolveEnvVarReferences,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@@ -479,9 +480,29 @@ function resolveEnvironmentVariables(
|
||||
const replacements: Array<{ match: string; index: number; varName: string; varValue: string }> =
|
||||
[]
|
||||
|
||||
const resolverVars: Record<string, string> = {}
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
resolverVars[key] = String(value)
|
||||
}
|
||||
})
|
||||
Object.entries(envVars).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
resolverVars[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const varName = match[1].trim()
|
||||
const varValue = envVars[varName] || params[varName] || ''
|
||||
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'empty',
|
||||
deep: false,
|
||||
})
|
||||
const varValue =
|
||||
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
|
||||
replacements.push({
|
||||
match: match[0],
|
||||
index: match.index,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServeAPI')
|
||||
@@ -52,6 +53,8 @@ async function getServer(serverId: string) {
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublic: workflowMcpServer.isPublic,
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
@@ -90,9 +93,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!server.isPublic) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
@@ -138,7 +143,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
id,
|
||||
serverId,
|
||||
rpcParams as { name: string; arguments?: Record<string, unknown> },
|
||||
apiKey
|
||||
apiKey,
|
||||
server.isPublic ? server.createdBy : undefined
|
||||
)
|
||||
|
||||
default:
|
||||
@@ -200,7 +206,8 @@ async function handleToolsCall(
|
||||
id: RequestId,
|
||||
serverId: string,
|
||||
params: { name: string; arguments?: Record<string, unknown> } | undefined,
|
||||
apiKey?: string | null
|
||||
apiKey?: string | null,
|
||||
publicServerOwnerId?: string
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
if (!params?.name) {
|
||||
@@ -243,7 +250,13 @@ async function handleToolsCall(
|
||||
|
||||
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['X-API-Key'] = apiKey
|
||||
|
||||
if (publicServerOwnerId) {
|
||||
const internalToken = await generateInternalToken(publicServerOwnerId)
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
} else if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey
|
||||
}
|
||||
|
||||
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import { McpClient } from '@/lib/mcp/client'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
|
||||
const logger = createLogger('McpServerTestAPI')
|
||||
|
||||
@@ -24,22 +23,23 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
|
||||
* Resolve environment variables in strings
|
||||
*/
|
||||
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
|
||||
const envVarPattern = createEnvVarPattern()
|
||||
const envMatches = value.match(envVarPattern)
|
||||
if (!envMatches) return value
|
||||
const missingVars: string[] = []
|
||||
const resolvedValue = resolveEnvVarReferences(value, envVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
missingKeys: missingVars,
|
||||
}) as string
|
||||
|
||||
let resolvedValue = value
|
||||
for (const match of envMatches) {
|
||||
const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim()
|
||||
const envValue = envVars[envKey]
|
||||
|
||||
if (envValue === undefined) {
|
||||
if (missingVars.length > 0) {
|
||||
const uniqueMissing = Array.from(new Set(missingVars))
|
||||
uniqueMissing.forEach((envKey) => {
|
||||
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
|
||||
continue
|
||||
}
|
||||
|
||||
resolvedValue = resolvedValue.replace(match, envValue)
|
||||
})
|
||||
}
|
||||
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublic: workflowMcpServer.isPublic,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
})
|
||||
@@ -98,6 +99,9 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
if (body.description !== undefined) {
|
||||
updateData.description = body.description?.trim() || null
|
||||
}
|
||||
if (body.isPublic !== undefined) {
|
||||
updateData.isPublic = body.isPublic
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
|
||||
@@ -26,7 +26,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
||||
|
||||
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
@@ -72,7 +71,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
|
||||
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
@@ -139,7 +137,6 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
|
||||
|
||||
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
|
||||
@@ -6,24 +6,10 @@ import type { NextRequest } from 'next/server'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
const logger = createLogger('WorkflowMcpToolsAPI')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
@@ -40,7 +26,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
||||
|
||||
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
@@ -53,7 +38,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Get tools with workflow details
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
@@ -107,7 +91,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
)
|
||||
}
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
@@ -120,7 +103,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow exists and is deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
@@ -137,7 +119,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow belongs to the same workspace
|
||||
if (workflowRecord.workspaceId !== workspaceId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow does not belong to this workspace'),
|
||||
@@ -154,7 +135,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow has a valid start block
|
||||
const hasStartBlock = await hasValidStartBlock(body.workflowId)
|
||||
if (!hasStartBlock) {
|
||||
return createMcpErrorResponse(
|
||||
@@ -164,7 +144,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
)
|
||||
}
|
||||
|
||||
// Check if tool already exists for this workflow
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
@@ -190,7 +169,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
workflowRecord.description ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
|
||||
// Create the tool
|
||||
const toolId = crypto.randomUUID()
|
||||
const [tool] = await db
|
||||
.insert(workflowMcpTool)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServersAPI')
|
||||
|
||||
@@ -25,18 +27,18 @@ export const GET = withMcpAuth('read')(
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublic: workflowMcpServer.isPublic,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
toolCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
|
||||
)`.as('tool_count'),
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
|
||||
// Fetch all tools for these servers
|
||||
const serverIds = servers.map((s) => s.id)
|
||||
const tools =
|
||||
serverIds.length > 0
|
||||
@@ -49,7 +51,6 @@ export const GET = withMcpAuth('read')(
|
||||
.where(inArray(workflowMcpTool.serverId, serverIds))
|
||||
: []
|
||||
|
||||
// Group tool names by server
|
||||
const toolNamesByServer: Record<string, string[]> = {}
|
||||
for (const tool of tools) {
|
||||
if (!toolNamesByServer[tool.serverId]) {
|
||||
@@ -58,7 +59,6 @@ export const GET = withMcpAuth('read')(
|
||||
toolNamesByServer[tool.serverId].push(tool.toolName)
|
||||
}
|
||||
|
||||
// Attach tool names to servers
|
||||
const serversWithToolNames = servers.map((server) => ({
|
||||
...server,
|
||||
toolNames: toolNamesByServer[server.id] || [],
|
||||
@@ -90,6 +90,7 @@ export const POST = withMcpAuth('write')(
|
||||
logger.info(`[${requestId}] Creating workflow MCP server:`, {
|
||||
name: body.name,
|
||||
workspaceId,
|
||||
workflowIds: body.workflowIds,
|
||||
})
|
||||
|
||||
if (!body.name) {
|
||||
@@ -110,16 +111,76 @@ export const POST = withMcpAuth('write')(
|
||||
createdBy: userId,
|
||||
name: body.name.trim(),
|
||||
description: body.description?.trim() || null,
|
||||
isPublic: body.isPublic ?? false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
const workflowIds: string[] = body.workflowIds || []
|
||||
const addedTools: Array<{ workflowId: string; toolName: string }> = []
|
||||
|
||||
if (workflowIds.length > 0) {
|
||||
const workflows = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
workspaceId: workflow.workspaceId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(inArray(workflow.id, workflowIds))
|
||||
|
||||
for (const workflowRecord of workflows) {
|
||||
if (workflowRecord.workspaceId !== workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!workflowRecord.isDeployed) {
|
||||
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`)
|
||||
continue
|
||||
}
|
||||
|
||||
const hasStartBlock = await hasValidStartBlock(workflowRecord.id)
|
||||
if (!hasStartBlock) {
|
||||
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`)
|
||||
continue
|
||||
}
|
||||
|
||||
const toolName = sanitizeToolName(workflowRecord.name)
|
||||
const toolDescription =
|
||||
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
|
||||
|
||||
const toolId = crypto.randomUUID()
|
||||
await db.insert(workflowMcpTool).values({
|
||||
id: toolId,
|
||||
serverId,
|
||||
workflowId: workflowRecord.id,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
addedTools.push({ workflowId: workflowRecord.id, toolName })
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`,
|
||||
addedTools.map((t) => t.toolName)
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ server }, 201)
|
||||
return createMcpSuccessResponse({ server, addedTools }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
not: vi.fn((condition) => ({ type: 'not', condition })),
|
||||
isNull: vi.fn((field) => ({ type: 'isNull', field })),
|
||||
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
|
||||
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => {
|
||||
@@ -92,6 +93,17 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
status: 'status',
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
deploymentVersionId: 'deploymentVersionId',
|
||||
},
|
||||
workflowDeploymentVersion: {
|
||||
id: 'id',
|
||||
workflowId: 'workflowId',
|
||||
isActive: 'isActive',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -134,6 +146,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
not: vi.fn((condition) => ({ type: 'not', condition })),
|
||||
isNull: vi.fn((field) => ({ type: 'isNull', field })),
|
||||
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
|
||||
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => {
|
||||
@@ -169,6 +182,17 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
status: 'status',
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
deploymentVersionId: 'deploymentVersionId',
|
||||
},
|
||||
workflowDeploymentVersion: {
|
||||
id: 'id',
|
||||
workflowId: 'workflowId',
|
||||
isActive: 'isActive',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -206,6 +230,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
not: vi.fn((condition) => ({ type: 'not', condition })),
|
||||
isNull: vi.fn((field) => ({ type: 'isNull', field })),
|
||||
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
|
||||
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => {
|
||||
@@ -228,6 +253,17 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
status: 'status',
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
deploymentVersionId: 'deploymentVersionId',
|
||||
},
|
||||
workflowDeploymentVersion: {
|
||||
id: 'id',
|
||||
workflowId: 'workflowId',
|
||||
isActive: 'isActive',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -265,6 +301,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
not: vi.fn((condition) => ({ type: 'not', condition })),
|
||||
isNull: vi.fn((field) => ({ type: 'isNull', field })),
|
||||
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
|
||||
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => {
|
||||
@@ -310,6 +347,17 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
status: 'status',
|
||||
nextRunAt: 'nextRunAt',
|
||||
lastQueuedAt: 'lastQueuedAt',
|
||||
deploymentVersionId: 'deploymentVersionId',
|
||||
},
|
||||
workflowDeploymentVersion: {
|
||||
id: 'id',
|
||||
workflowId: 'workflowId',
|
||||
isActive: 'isActive',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db, workflowSchedule } from '@sim/db'
|
||||
import { db, workflowDeploymentVersion, workflowSchedule } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm'
|
||||
import { and, eq, isNull, lt, lte, not, or, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
@@ -37,7 +37,8 @@ export async function GET(request: NextRequest) {
|
||||
or(
|
||||
isNull(workflowSchedule.lastQueuedAt),
|
||||
lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt)
|
||||
)
|
||||
),
|
||||
sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)`
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
|
||||
@@ -29,12 +29,23 @@ vi.mock('@sim/db', () => ({
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
|
||||
workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' },
|
||||
workflowSchedule: {
|
||||
workflowId: 'workflowId',
|
||||
blockId: 'blockId',
|
||||
deploymentVersionId: 'deploymentVersionId',
|
||||
},
|
||||
workflowDeploymentVersion: {
|
||||
id: 'id',
|
||||
workflowId: 'workflowId',
|
||||
isActive: 'isActive',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
isNull: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
@@ -56,6 +67,11 @@ function mockDbChain(results: any[]) {
|
||||
where: () => ({
|
||||
limit: () => results[callIndex++] || [],
|
||||
}),
|
||||
leftJoin: () => ({
|
||||
where: () => ({
|
||||
limit: () => results[callIndex++] || [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -74,7 +90,16 @@ describe('Schedule GET API', () => {
|
||||
it('returns schedule data for authorized user', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
[{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }],
|
||||
[
|
||||
{
|
||||
schedule: {
|
||||
id: 'sched-1',
|
||||
cronExpression: '0 9 * * *',
|
||||
status: 'active',
|
||||
failedCount: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
@@ -128,7 +153,7 @@ describe('Schedule GET API', () => {
|
||||
it('allows workspace members to view', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
[{ id: 'sched-1', status: 'active', failedCount: 0 }],
|
||||
[{ schedule: { id: 'sched-1', status: 'active', failedCount: 0 } }],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
@@ -139,7 +164,7 @@ describe('Schedule GET API', () => {
|
||||
it('indicates disabled schedule with failures', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
[{ id: 'sched-1', status: 'disabled', failedCount: 100 }],
|
||||
[{ schedule: { id: 'sched-1', status: 'disabled', failedCount: 100 } }],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowSchedule } from '@sim/db/schema'
|
||||
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
@@ -62,9 +62,24 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
const schedule = await db
|
||||
.select()
|
||||
.select({ schedule: workflowSchedule })
|
||||
.from(workflowSchedule)
|
||||
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...conditions,
|
||||
or(
|
||||
eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id),
|
||||
and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId))
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const headers = new Headers()
|
||||
@@ -74,7 +89,7 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ schedule: null }, { headers })
|
||||
}
|
||||
|
||||
const scheduleData = schedule[0]
|
||||
const scheduleData = schedule[0].schedule
|
||||
const isDisabled = scheduleData.status === 'disabled'
|
||||
const hasFailures = scheduleData.failedCount > 0
|
||||
|
||||
|
||||
@@ -60,7 +60,17 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
||||
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
|
||||
}
|
||||
|
||||
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
|
||||
if (!deployResult.deploymentVersionId) {
|
||||
await undeployWorkflow({ workflowId })
|
||||
return internalErrorResponse('Failed to resolve deployment version')
|
||||
}
|
||||
|
||||
const scheduleResult = await createSchedulesForDeploy(
|
||||
workflowId,
|
||||
normalizedData.blocks,
|
||||
db,
|
||||
deployResult.deploymentVersionId
|
||||
)
|
||||
if (!scheduleResult.success) {
|
||||
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook, workflow } from '@sim/db/schema'
|
||||
import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -71,7 +71,23 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(webhook.workflowId, workflowId),
|
||||
eq(webhook.blockId, blockId),
|
||||
or(
|
||||
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
|
||||
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(webhook.updatedAt))
|
||||
|
||||
logger.info(
|
||||
@@ -149,7 +165,23 @@ export async function POST(request: NextRequest) {
|
||||
const existingForBlock = await db
|
||||
.select({ id: webhook.id, path: webhook.path })
|
||||
.from(webhook)
|
||||
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(webhook.workflowId, workflowId),
|
||||
eq(webhook.blockId, blockId),
|
||||
or(
|
||||
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
|
||||
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingForBlock.length > 0) {
|
||||
@@ -225,7 +257,23 @@ export async function POST(request: NextRequest) {
|
||||
const existingForBlock = await db
|
||||
.select({ id: webhook.id })
|
||||
.from(webhook)
|
||||
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(webhook.workflowId, workflowId),
|
||||
eq(webhook.blockId, blockId),
|
||||
or(
|
||||
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
|
||||
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (existingForBlock.length > 0) {
|
||||
targetWebhookId = existingForBlock[0].id
|
||||
|
||||
@@ -152,7 +152,6 @@ export async function POST(
|
||||
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
|
||||
requestId,
|
||||
path,
|
||||
executionTarget: 'deployed',
|
||||
})
|
||||
responses.push(response)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
.select({
|
||||
id: chat.id,
|
||||
identifier: chat.identifier,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
password: chat.password,
|
||||
isActive: chat.isActive,
|
||||
})
|
||||
.from(chat)
|
||||
@@ -34,6 +41,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
? {
|
||||
id: deploymentResults[0].id,
|
||||
identifier: deploymentResults[0].identifier,
|
||||
title: deploymentResults[0].title,
|
||||
description: deploymentResults[0].description,
|
||||
customizations: deploymentResults[0].customizations,
|
||||
authType: deploymentResults[0].authType,
|
||||
allowedEmails: deploymentResults[0].allowedEmails,
|
||||
outputConfigs: deploymentResults[0].outputConfigs,
|
||||
hasPassword: Boolean(deploymentResults[0].password),
|
||||
}
|
||||
: null
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
undeployWorkflow,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
|
||||
import {
|
||||
cleanupDeploymentVersion,
|
||||
createSchedulesForDeploy,
|
||||
validateWorkflowSchedules,
|
||||
} from '@/lib/workflows/schedules'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -131,22 +135,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
|
||||
}
|
||||
|
||||
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
|
||||
request,
|
||||
workflowId: id,
|
||||
workflow: workflowData,
|
||||
userId: actorUserId,
|
||||
blocks: normalizedData.blocks,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (!triggerSaveResult.success) {
|
||||
return createErrorResponse(
|
||||
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
|
||||
triggerSaveResult.error?.status || 500
|
||||
)
|
||||
}
|
||||
|
||||
const deployResult = await deployWorkflow({
|
||||
workflowId: id,
|
||||
deployedBy: actorUserId,
|
||||
@@ -158,14 +146,58 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const deployedAt = deployResult.deployedAt!
|
||||
const deploymentVersionId = deployResult.deploymentVersionId
|
||||
|
||||
if (!deploymentVersionId) {
|
||||
await undeployWorkflow({ workflowId: id })
|
||||
return createErrorResponse('Failed to resolve deployment version', 500)
|
||||
}
|
||||
|
||||
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
|
||||
request,
|
||||
workflowId: id,
|
||||
workflow: workflowData,
|
||||
userId: actorUserId,
|
||||
blocks: normalizedData.blocks,
|
||||
requestId,
|
||||
deploymentVersionId,
|
||||
})
|
||||
|
||||
if (!triggerSaveResult.success) {
|
||||
await cleanupDeploymentVersion({
|
||||
workflowId: id,
|
||||
workflow: workflowData as Record<string, unknown>,
|
||||
requestId,
|
||||
deploymentVersionId,
|
||||
})
|
||||
await undeployWorkflow({ workflowId: id })
|
||||
return createErrorResponse(
|
||||
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
|
||||
triggerSaveResult.error?.status || 500
|
||||
)
|
||||
}
|
||||
|
||||
let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {}
|
||||
const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db)
|
||||
const scheduleResult = await createSchedulesForDeploy(
|
||||
id,
|
||||
normalizedData.blocks,
|
||||
db,
|
||||
deploymentVersionId
|
||||
)
|
||||
if (!scheduleResult.success) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}`
|
||||
)
|
||||
} else if (scheduleResult.scheduleId) {
|
||||
await cleanupDeploymentVersion({
|
||||
workflowId: id,
|
||||
workflow: workflowData as Record<string, unknown>,
|
||||
requestId,
|
||||
deploymentVersionId,
|
||||
})
|
||||
await undeployWorkflow({ workflowId: id })
|
||||
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
|
||||
}
|
||||
if (scheduleResult.scheduleId) {
|
||||
scheduleInfo = {
|
||||
scheduleId: scheduleResult.scheduleId,
|
||||
cronExpression: scheduleResult.cronExpression,
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { db, workflowDeploymentVersion } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
|
||||
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
cleanupDeploymentVersion,
|
||||
createSchedulesForDeploy,
|
||||
validateWorkflowSchedules,
|
||||
} from '@/lib/workflows/schedules'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowActivateDeploymentAPI')
|
||||
|
||||
@@ -19,30 +28,135 @@ 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)
|
||||
}
|
||||
|
||||
const actorUserId = session?.user?.id
|
||||
if (!actorUserId) {
|
||||
logger.warn(`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`)
|
||||
return createErrorResponse('Unable to determine activating user', 400)
|
||||
}
|
||||
|
||||
const versionNum = Number(version)
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
return createErrorResponse('Invalid version number', 400)
|
||||
}
|
||||
|
||||
const [versionRow] = await db
|
||||
.select({
|
||||
id: workflowDeploymentVersion.id,
|
||||
state: workflowDeploymentVersion.state,
|
||||
})
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!versionRow?.state) {
|
||||
return createErrorResponse('Deployment version not found', 404)
|
||||
}
|
||||
|
||||
const [currentActiveVersion] = await db
|
||||
.select({ id: workflowDeploymentVersion.id })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const previousVersionId = currentActiveVersion?.id
|
||||
|
||||
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
|
||||
const blocks = deployedState.blocks
|
||||
if (!blocks || typeof blocks !== 'object') {
|
||||
return createErrorResponse('Invalid deployed state structure', 500)
|
||||
}
|
||||
|
||||
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
|
||||
request,
|
||||
workflowId: id,
|
||||
workflow: workflowData as Record<string, unknown>,
|
||||
userId: actorUserId,
|
||||
blocks,
|
||||
requestId,
|
||||
deploymentVersionId: versionRow.id,
|
||||
})
|
||||
|
||||
if (!triggerSaveResult.success) {
|
||||
return createErrorResponse(
|
||||
triggerSaveResult.error?.message || 'Failed to sync trigger configuration',
|
||||
triggerSaveResult.error?.status || 500
|
||||
)
|
||||
}
|
||||
|
||||
const scheduleValidation = validateWorkflowSchedules(blocks)
|
||||
if (!scheduleValidation.isValid) {
|
||||
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
|
||||
}
|
||||
|
||||
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
|
||||
|
||||
if (!scheduleResult.success) {
|
||||
await cleanupDeploymentVersion({
|
||||
workflowId: id,
|
||||
workflow: workflowData as Record<string, unknown>,
|
||||
requestId,
|
||||
deploymentVersionId: versionRow.id,
|
||||
})
|
||||
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
|
||||
}
|
||||
|
||||
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
|
||||
if (!result.success) {
|
||||
await cleanupDeploymentVersion({
|
||||
workflowId: id,
|
||||
workflow: workflowData as Record<string, unknown>,
|
||||
requestId,
|
||||
deploymentVersionId: versionRow.id,
|
||||
})
|
||||
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
|
||||
}
|
||||
|
||||
if (result.state) {
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: id,
|
||||
requestId,
|
||||
state: result.state,
|
||||
context: 'activate',
|
||||
})
|
||||
if (previousVersionId && previousVersionId !== versionRow.id) {
|
||||
try {
|
||||
logger.info(
|
||||
`[${requestId}] Cleaning up previous version ${previousVersionId} webhooks/schedules`
|
||||
)
|
||||
await cleanupDeploymentVersion({
|
||||
workflowId: id,
|
||||
workflow: workflowData as Record<string, unknown>,
|
||||
requestId,
|
||||
deploymentVersionId: previousVersionId,
|
||||
})
|
||||
logger.info(`[${requestId}] Previous version cleanup completed`)
|
||||
} catch (cleanupError) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
|
||||
cleanupError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: id,
|
||||
requestId,
|
||||
state: versionRow.state,
|
||||
context: 'activate',
|
||||
})
|
||||
|
||||
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
||||
|
||||
@@ -110,6 +110,7 @@ type AsyncExecutionParams = {
|
||||
userId: string
|
||||
input: any
|
||||
triggerType: CoreTriggerType
|
||||
preflighted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +133,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
userId,
|
||||
input,
|
||||
triggerType,
|
||||
preflighted: params.preflighted,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -264,6 +266,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId
|
||||
)
|
||||
|
||||
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
@@ -272,6 +275,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId,
|
||||
checkDeployment: !shouldUseDraftState,
|
||||
loggingSession,
|
||||
preflightEnvVars: shouldPreflightEnvVars,
|
||||
useDraftState: shouldUseDraftState,
|
||||
envUserId: isClientSession ? userId : undefined,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
@@ -303,6 +309,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
userId: actorUserId,
|
||||
input,
|
||||
triggerType: loggingTriggerType,
|
||||
preflighted: shouldPreflightEnvVars,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function DeleteChunkModal({
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' disabled={isDeleting} onClick={onClose}>
|
||||
<Button variant='default' disabled={isDeleting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDeleteChunk} disabled={isDeleting}>
|
||||
|
||||
@@ -392,7 +392,7 @@ export function DocumentTagsModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Document Tags</span>
|
||||
@@ -486,7 +486,7 @@ export function DocumentTagsModal({
|
||||
/>
|
||||
)}
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
<span className='text-[12px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
@@ -639,7 +639,7 @@ export function DocumentTagsModal({
|
||||
/>
|
||||
)}
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
<span className='text-[12px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('Document')
|
||||
|
||||
@@ -313,69 +313,22 @@ export function Document({
|
||||
isFetching: isFetchingChunks,
|
||||
} = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL)
|
||||
|
||||
const [searchResults, setSearchResults] = useState<ChunkData[]>([])
|
||||
const [isLoadingSearch, setIsLoadingSearch] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
setSearchResults([])
|
||||
setSearchError(null)
|
||||
return
|
||||
const {
|
||||
data: searchResults = [],
|
||||
isLoading: isLoadingSearch,
|
||||
error: searchQueryError,
|
||||
} = useDocumentChunkSearchQuery(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
search: debouncedSearchQuery,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(debouncedSearchQuery.trim()),
|
||||
}
|
||||
)
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const searchAllChunks = async () => {
|
||||
try {
|
||||
setIsLoadingSearch(true)
|
||||
setSearchError(null)
|
||||
|
||||
const allResults: ChunkData[] = []
|
||||
let hasMore = true
|
||||
let offset = 0
|
||||
const limit = 100
|
||||
|
||||
while (hasMore && isMounted) {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks?search=${encodeURIComponent(debouncedSearchQuery)}&limit=${limit}&offset=${offset}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Search failed')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.data) {
|
||||
allResults.push(...result.data)
|
||||
hasMore = result.pagination?.hasMore || false
|
||||
offset += limit
|
||||
} else {
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
setSearchResults(allResults)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setSearchError(err instanceof Error ? err.message : 'Search failed')
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoadingSearch(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchAllChunks()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [debouncedSearchQuery, knowledgeBaseId, documentId])
|
||||
const searchError = searchQueryError instanceof Error ? searchQueryError.message : null
|
||||
|
||||
const [selectedChunks, setSelectedChunks] = useState<Set<string>>(new Set())
|
||||
const [selectedChunk, setSelectedChunk] = useState<ChunkData | null>(null)
|
||||
@@ -1208,15 +1161,19 @@ export function Document({
|
||||
<ModalHeader>Delete Document</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete "{effectiveDocumentName}"? This will permanently
|
||||
delete the document and all {documentData?.chunkCount ?? 0} chunk
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{effectiveDocumentName}
|
||||
</span>
|
||||
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
|
||||
chunk
|
||||
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => setShowDeleteDocumentDialog(false)}
|
||||
disabled={isDeletingDocument}
|
||||
>
|
||||
|
||||
@@ -1523,15 +1523,16 @@ export function KnowledgeBase({
|
||||
<ModalHeader>Delete Knowledge Base</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete "{knowledgeBaseName}"? This will permanently delete
|
||||
the knowledge base and all {pagination.total} document
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
|
||||
This will permanently delete the knowledge base and all {pagination.total} document
|
||||
{pagination.total === 1 ? '' : 's'} within it.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
@@ -1549,14 +1550,16 @@ export function KnowledgeBase({
|
||||
<ModalHeader>Delete Document</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete "
|
||||
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}"?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}
|
||||
</span>
|
||||
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
setShowDeleteDocumentModal(false)
|
||||
setDocumentToDelete(null)
|
||||
@@ -1582,7 +1585,7 @@ export function KnowledgeBase({
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={() => setShowBulkDeleteModal(false)}>
|
||||
<Button variant='default' onClick={() => setShowBulkDeleteModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={confirmBulkDelete} disabled={isBulkOperating}>
|
||||
|
||||
@@ -221,14 +221,14 @@ export function AddDocumentsModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Add Documents</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
{fileError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
@@ -336,7 +336,7 @@ export function AddDocumentsModal({
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{uploadError ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{uploadError.message}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -306,7 +306,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Tags</span>
|
||||
@@ -400,7 +400,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
}}
|
||||
/>
|
||||
{tagNameConflict && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
<span className='text-[12px] text-[var(--text-error)]'>
|
||||
A tag with this name already exists
|
||||
</span>
|
||||
)}
|
||||
@@ -417,7 +417,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
placeholder='Select type'
|
||||
/>
|
||||
{!hasAvailableSlots(createTagForm.fieldType) && (
|
||||
<span className='text-[11px] text-[var(--text-error)]'>
|
||||
<span className='text-[12px] text-[var(--text-error)]'>
|
||||
No available slots for this type. Choose a different type.
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function RenameDocumentModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Rename Document</ModalHeader>
|
||||
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
@@ -108,7 +108,7 @@ export function RenameDocumentModal({
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{error ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -332,7 +332,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent>
|
||||
<ModalContent size='lg'>
|
||||
<ModalHeader>Create Knowledge Base</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
@@ -528,7 +528,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
)}
|
||||
|
||||
{fileError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -537,7 +537,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{submitStatus?.type === 'error' || uploadError ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{uploadError?.message || submitStatus?.message}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -38,7 +38,7 @@ export function DeleteKnowledgeBaseModal({
|
||||
}: DeleteKnowledgeBaseModalProps) {
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Knowledge Base</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -55,7 +55,7 @@ export function DeleteKnowledgeBaseModal({
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={onClose} disabled={isDeleting}>
|
||||
<Button variant='default' onClick={onClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={onConfirm} disabled={isDeleting}>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function EditKnowledgeBaseModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Edit Knowledge Base</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
@@ -118,7 +118,7 @@ export function EditKnowledgeBaseModal({
|
||||
data-form-type='other'
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{errors.name.message}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ export function EditKnowledgeBaseModal({
|
||||
className={cn(errors.description && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -143,7 +143,7 @@ export function EditKnowledgeBaseModal({
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{error ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -112,7 +112,7 @@ export function SlackChannelSelector({
|
||||
{selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className='text-[11px] text-[var(--text-error)]'>{error}</p>}
|
||||
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Badge, Combobox, type ComboboxOption } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
|
||||
interface WorkflowSelectorProps {
|
||||
workspaceId: string
|
||||
@@ -25,26 +26,9 @@ export function WorkflowSelector({
|
||||
onChange,
|
||||
error,
|
||||
}: WorkflowSelectorProps) {
|
||||
const [workflows, setWorkflows] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setWorkflows(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
setWorkflows([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [workspaceId])
|
||||
const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, {
|
||||
syncRegistry: false,
|
||||
})
|
||||
|
||||
const options: ComboboxOption[] = useMemo(() => {
|
||||
return workflows.map((w) => ({
|
||||
|
||||
@@ -634,7 +634,7 @@ export function NotificationSettings({
|
||||
}}
|
||||
/>
|
||||
{formErrors.webhookUrl && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.webhookUrl}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.webhookUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
@@ -660,7 +660,7 @@ export function NotificationSettings({
|
||||
placeholderWithTags='Add email'
|
||||
/>
|
||||
{formErrors.emailRecipients && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -707,7 +707,7 @@ export function NotificationSettings({
|
||||
/>
|
||||
)}
|
||||
{formErrors.slackAccountId && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{formErrors.slackAccountId}
|
||||
</p>
|
||||
)}
|
||||
@@ -776,7 +776,7 @@ export function NotificationSettings({
|
||||
allOptionLabel='All levels'
|
||||
/>
|
||||
{formErrors.levelFilter && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.levelFilter}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.levelFilter}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -822,7 +822,7 @@ export function NotificationSettings({
|
||||
allOptionLabel='All triggers'
|
||||
/>
|
||||
{formErrors.triggerFilter && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.triggerFilter}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.triggerFilter}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -938,7 +938,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.consecutiveFailures && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{formErrors.consecutiveFailures}
|
||||
</p>
|
||||
)}
|
||||
@@ -962,7 +962,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.failureRatePercent && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{formErrors.failureRatePercent}
|
||||
</p>
|
||||
)}
|
||||
@@ -982,7 +982,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.windowHours && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1004,7 +1004,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.durationThresholdMs && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{formErrors.durationThresholdMs}
|
||||
</p>
|
||||
)}
|
||||
@@ -1028,7 +1028,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.latencySpikePercent && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{formErrors.latencySpikePercent}
|
||||
</p>
|
||||
)}
|
||||
@@ -1048,7 +1048,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.windowHours && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1071,7 +1071,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.costThresholdDollars && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{formErrors.costThresholdDollars}
|
||||
</p>
|
||||
)}
|
||||
@@ -1094,7 +1094,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.inactivityHours && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.inactivityHours}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.inactivityHours}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1116,7 +1116,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.errorCountThreshold && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{formErrors.errorCountThreshold}
|
||||
</p>
|
||||
)}
|
||||
@@ -1136,7 +1136,7 @@ export function NotificationSettings({
|
||||
}
|
||||
/>
|
||||
{formErrors.windowHours && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1261,7 +1261,7 @@ export function NotificationSettings({
|
||||
</Modal>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Notification</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useCallback } from 'react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
||||
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -97,7 +98,7 @@ export const ActionBar = memo(
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
|
||||
const isStartBlock = isValidStartBlockType(blockType)
|
||||
const isResponseBlock = blockType === 'response'
|
||||
const isNoteBlock = blockType === 'note'
|
||||
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
|
||||
/**
|
||||
* Block information for context menu actions
|
||||
@@ -73,9 +74,7 @@ export function BlockMenu({
|
||||
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
||||
const allDisabled = selectedBlocks.every((b) => !b.enabled)
|
||||
|
||||
const hasStarterBlock = selectedBlocks.some(
|
||||
(b) => b.type === 'starter' || b.type === 'start_trigger'
|
||||
)
|
||||
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
|
||||
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
|
||||
const isSubflow =
|
||||
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
|
||||
|
||||
@@ -995,7 +995,7 @@ export function Chat() {
|
||||
<div className='flex items-start gap-2'>
|
||||
<AlertCircle className='mt-0.5 h-3 w-3 shrink-0 text-[var(--text-error)]' />
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1 font-medium text-[11px] text-[var(--text-error)]'>
|
||||
<div className='mb-1 font-medium text-[12px] text-[var(--text-error)]'>
|
||||
File upload error
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
|
||||
@@ -9,8 +9,6 @@ import { useCopilotStore, usePanelStore } from '@/stores/panel'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('DiffControls')
|
||||
const NOTIFICATION_WIDTH = 240
|
||||
@@ -19,26 +17,22 @@ const NOTIFICATION_GAP = 16
|
||||
export const DiffControls = memo(function DiffControls() {
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
|
||||
useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
|
||||
const { updatePreviewToolCallState } = useCopilotStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
updatePreviewToolCallState: state.updatePreviewToolCallState,
|
||||
currentChat: state.currentChat,
|
||||
messages: state.messages,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
@@ -54,154 +48,6 @@ export const DiffControls = memo(function DiffControls() {
|
||||
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
|
||||
}, [allNotifications, activeWorkflowId])
|
||||
|
||||
const createCheckpoint = useCallback(async () => {
|
||||
if (!activeWorkflowId || !currentChat?.id) {
|
||||
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
|
||||
workflowId: activeWorkflowId,
|
||||
chatId: currentChat?.id,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Creating checkpoint before accepting changes')
|
||||
|
||||
// Use the baseline workflow (state before diff) instead of current state
|
||||
// This ensures reverting to the checkpoint restores the pre-diff state
|
||||
const rawState = baselineWorkflow || useWorkflowStore.getState().getWorkflowState()
|
||||
|
||||
// The baseline already has merged subblock values, but we'll merge again to be safe
|
||||
// This ensures all user inputs and subblock data are captured
|
||||
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, activeWorkflowId)
|
||||
|
||||
// Filter and complete blocks to ensure all required fields are present
|
||||
// This matches the validation logic from /api/workflows/[id]/state
|
||||
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
|
||||
(acc, [blockId, block]) => {
|
||||
if (block.type && block.name) {
|
||||
// Ensure all required fields are present
|
||||
acc[blockId] = {
|
||||
...block,
|
||||
id: block.id || blockId, // Ensure id field is set
|
||||
enabled: block.enabled !== undefined ? block.enabled : true,
|
||||
horizontalHandles:
|
||||
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
|
||||
height: block.height !== undefined ? block.height : 90,
|
||||
subBlocks: block.subBlocks || {},
|
||||
outputs: block.outputs || {},
|
||||
data: block.data || {},
|
||||
position: block.position || { x: 0, y: 0 }, // Ensure position exists
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as typeof rawState.blocks
|
||||
)
|
||||
|
||||
// Clean the workflow state - only include valid fields, exclude null/undefined values
|
||||
const workflowState = {
|
||||
blocks: filteredBlocks,
|
||||
edges: rawState.edges || [],
|
||||
loops: rawState.loops || {},
|
||||
parallels: rawState.parallels || {},
|
||||
lastSaved: rawState.lastSaved || Date.now(),
|
||||
deploymentStatuses: rawState.deploymentStatuses || {},
|
||||
}
|
||||
|
||||
logger.info('Prepared complete workflow state for checkpoint', {
|
||||
blocksCount: Object.keys(workflowState.blocks).length,
|
||||
edgesCount: workflowState.edges.length,
|
||||
loopsCount: Object.keys(workflowState.loops).length,
|
||||
parallelsCount: Object.keys(workflowState.parallels).length,
|
||||
hasRequiredFields: Object.values(workflowState.blocks).every(
|
||||
(block) => block.id && block.type && block.name && block.position
|
||||
),
|
||||
hasSubblockValues: Object.values(workflowState.blocks).some((block) =>
|
||||
Object.values(block.subBlocks || {}).some(
|
||||
(subblock) => subblock.value !== null && subblock.value !== undefined
|
||||
)
|
||||
),
|
||||
sampleBlock: Object.values(workflowState.blocks)[0],
|
||||
})
|
||||
|
||||
// Find the most recent user message ID from the current chat
|
||||
const userMessages = messages.filter((msg) => msg.role === 'user')
|
||||
const lastUserMessage = userMessages[userMessages.length - 1]
|
||||
const messageId = lastUserMessage?.id
|
||||
|
||||
logger.info('Creating checkpoint with message association', {
|
||||
totalMessages: messages.length,
|
||||
userMessageCount: userMessages.length,
|
||||
lastUserMessageId: messageId,
|
||||
chatId: currentChat.id,
|
||||
entireMessageArray: messages,
|
||||
allMessageIds: messages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content.substring(0, 50),
|
||||
})),
|
||||
selectedUserMessages: userMessages.map((m) => ({
|
||||
id: m.id,
|
||||
content: m.content.substring(0, 100),
|
||||
})),
|
||||
allRawMessageIds: messages.map((m) => m.id),
|
||||
userMessageIds: userMessages.map((m) => m.id),
|
||||
checkpointData: {
|
||||
workflowId: activeWorkflowId,
|
||||
chatId: currentChat.id,
|
||||
messageId: messageId,
|
||||
messageFound: !!lastUserMessage,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await fetch('/api/copilot/checkpoints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId: activeWorkflowId,
|
||||
chatId: currentChat.id,
|
||||
messageId,
|
||||
workflowState: JSON.stringify(workflowState),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const newCheckpoint = result.checkpoint
|
||||
|
||||
logger.info('Checkpoint created successfully', {
|
||||
messageId,
|
||||
chatId: currentChat.id,
|
||||
checkpointId: newCheckpoint?.id,
|
||||
})
|
||||
|
||||
// Update the copilot store immediately to show the checkpoint icon
|
||||
if (newCheckpoint && messageId) {
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const existingCheckpoints = currentCheckpoints[messageId] || []
|
||||
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[messageId]: [newCheckpoint, ...existingCheckpoints],
|
||||
}
|
||||
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
logger.info('Updated copilot store with new checkpoint', {
|
||||
messageId,
|
||||
checkpointId: newCheckpoint.id,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to create checkpoint:', error)
|
||||
return false
|
||||
}
|
||||
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
@@ -238,12 +84,8 @@ export const DiffControls = memo(function DiffControls() {
|
||||
})
|
||||
|
||||
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
|
||||
createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint after accept:', error)
|
||||
})
|
||||
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
|
||||
}, [updatePreviewToolCallState, acceptChanges])
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
logger.info('Rejecting proposed changes (optimistic)')
|
||||
|
||||
@@ -168,12 +168,17 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
|
||||
)
|
||||
})
|
||||
|
||||
export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlockNodeData>) {
|
||||
export const NoteBlock = memo(function NoteBlock({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<NoteBlockNodeData>) {
|
||||
const { type, config, name } = data
|
||||
|
||||
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
|
||||
blockId: id,
|
||||
data,
|
||||
isSelected: selected,
|
||||
})
|
||||
const storedValues = useSubBlockStore(
|
||||
useCallback(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
|
||||
/**
|
||||
@@ -6,14 +7,23 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
|
||||
*/
|
||||
const CHARACTER_DELAY = 3
|
||||
|
||||
/**
|
||||
* Props for the StreamingIndicator component
|
||||
*/
|
||||
interface StreamingIndicatorProps {
|
||||
/** Optional class name for layout adjustments */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* StreamingIndicator shows animated dots during message streaming
|
||||
* Used as a standalone indicator when no content has arrived yet
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Animated loading indicator
|
||||
*/
|
||||
export const StreamingIndicator = memo(() => (
|
||||
<div className='flex h-[1.25rem] items-center text-muted-foreground'>
|
||||
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
|
||||
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
|
||||
<div className='flex space-x-0.5'>
|
||||
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
|
||||
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||
|
||||
/**
|
||||
* Removes thinking tags (raw or escaped) from streamed content.
|
||||
*/
|
||||
function stripThinkingTags(text: string): string {
|
||||
return text
|
||||
.replace(/<\/?thinking[^>]*>/gi, '')
|
||||
.replace(/<\/?thinking[^&]*>/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Max height for thinking content before internal scrolling kicks in
|
||||
*/
|
||||
@@ -187,6 +197,9 @@ export function ThinkingBlock({
|
||||
label = 'Thought',
|
||||
hasSpecialTags = false,
|
||||
}: ThinkingBlockProps) {
|
||||
// Strip thinking tags from content on render to handle persisted messages
|
||||
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
|
||||
@@ -209,10 +222,10 @@ export function ThinkingBlock({
|
||||
return
|
||||
}
|
||||
|
||||
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
||||
if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
|
||||
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
|
||||
|
||||
// Reset start time when streaming begins
|
||||
useEffect(() => {
|
||||
@@ -298,7 +311,7 @@ export function ThinkingBlock({
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = content && content.trim().length > 0
|
||||
const hasContent = cleanContent.length > 0
|
||||
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
@@ -374,7 +387,10 @@ export function ThinkingBlock({
|
||||
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} />
|
||||
<SmoothThinkingText
|
||||
content={cleanContent}
|
||||
isStreaming={isStreaming && !hasFollowingContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -412,7 +428,7 @@ export function ThinkingBlock({
|
||||
>
|
||||
{/* Completed thinking text - dimmed with markdown */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
<CopilotMarkdownRenderer content={cleanContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
@@ -93,6 +93,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
// UI state
|
||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||
|
||||
const cancelEditRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Checkpoint management hook
|
||||
const {
|
||||
showRestoreConfirmation,
|
||||
@@ -112,7 +114,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
messages,
|
||||
messageCheckpoints,
|
||||
onRevertModeChange,
|
||||
onEditModeChange
|
||||
onEditModeChange,
|
||||
() => cancelEditRef.current?.()
|
||||
)
|
||||
|
||||
// Message editing hook
|
||||
@@ -142,6 +145,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
pendingEditRef,
|
||||
})
|
||||
|
||||
cancelEditRef.current = handleCancelEdit
|
||||
|
||||
// Get clean text content with double newline parsing
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (!message.content) return ''
|
||||
@@ -488,8 +493,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
{/* Content blocks in chronological order */}
|
||||
{memoizedContentBlocks}
|
||||
|
||||
{/* Streaming indicator always at bottom during streaming */}
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
{isStreaming && (
|
||||
<StreamingIndicator className={!hasVisibleContent ? 'mt-1' : undefined} />
|
||||
)}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='flex gap-1.5'>
|
||||
|
||||
@@ -22,7 +22,8 @@ export function useCheckpointManagement(
|
||||
messages: CopilotMessage[],
|
||||
messageCheckpoints: any[],
|
||||
onRevertModeChange?: (isReverting: boolean) => void,
|
||||
onEditModeChange?: (isEditing: boolean) => void
|
||||
onEditModeChange?: (isEditing: boolean) => void,
|
||||
onCancelEdit?: () => void
|
||||
) {
|
||||
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
|
||||
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
|
||||
@@ -57,7 +58,7 @@ export function useCheckpointManagement(
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1),
|
||||
[message.id]: [],
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
@@ -93,7 +94,6 @@ export function useCheckpointManagement(
|
||||
|
||||
setShowRestoreConfirmation(false)
|
||||
onRevertModeChange?.(false)
|
||||
onEditModeChange?.(true)
|
||||
|
||||
logger.info('Checkpoint reverted and removed from message', {
|
||||
messageId: message.id,
|
||||
@@ -114,7 +114,6 @@ export function useCheckpointManagement(
|
||||
messages,
|
||||
currentChat,
|
||||
onRevertModeChange,
|
||||
onEditModeChange,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -140,7 +139,7 @@ export function useCheckpointManagement(
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1),
|
||||
[message.id]: [],
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
@@ -154,6 +153,8 @@ export function useCheckpointManagement(
|
||||
}
|
||||
|
||||
setShowCheckpointDiscardModal(false)
|
||||
onEditModeChange?.(false)
|
||||
onCancelEdit?.()
|
||||
|
||||
const { sendMessage } = useCopilotStore.getState()
|
||||
if (pendingEditRef.current) {
|
||||
@@ -173,6 +174,7 @@ export function useCheckpointManagement(
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
}
|
||||
pendingEditRef.current = null
|
||||
@@ -180,15 +182,17 @@ export function useCheckpointManagement(
|
||||
} finally {
|
||||
setIsProcessingDiscard(false)
|
||||
}
|
||||
}, [messageCheckpoints, revertToCheckpoint, message, messages])
|
||||
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
|
||||
|
||||
/**
|
||||
* Cancels checkpoint discard and clears pending edit
|
||||
*/
|
||||
const handleCancelCheckpointDiscard = useCallback(() => {
|
||||
setShowCheckpointDiscardModal(false)
|
||||
onEditModeChange?.(false)
|
||||
onCancelEdit?.()
|
||||
pendingEditRef.current = null
|
||||
}, [])
|
||||
}, [onEditModeChange, onCancelEdit])
|
||||
|
||||
/**
|
||||
* Continues with edit WITHOUT reverting checkpoint
|
||||
@@ -214,11 +218,12 @@ export function useCheckpointManagement(
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
}
|
||||
pendingEditRef.current = null
|
||||
}
|
||||
}, [message, messages])
|
||||
}, [message, messages, onEditModeChange, onCancelEdit])
|
||||
|
||||
/**
|
||||
* Handles keyboard events for restore confirmation (Escape/Enter)
|
||||
|
||||
@@ -166,6 +166,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1446,8 +1446,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
|
||||
blockType = blockType || op.block_type || ''
|
||||
}
|
||||
|
||||
// Fallback name to type or ID
|
||||
if (!blockName) blockName = blockType || blockId
|
||||
if (!blockName) blockName = blockType || ''
|
||||
if (!blockName && !blockType) {
|
||||
continue
|
||||
}
|
||||
|
||||
const change: BlockChange = { blockId, blockName, blockType }
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ interface UseContextManagementProps {
|
||||
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
||||
const initializedRef = useRef(false)
|
||||
const escapeRegex = useCallback((value: string) => {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}, [])
|
||||
|
||||
// Initialize with initial contexts when they're first provided (for edit mode)
|
||||
useEffect(() => {
|
||||
@@ -78,10 +81,10 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
||||
// Check for slash command tokens or mention tokens based on kind
|
||||
const isSlashCommand = c.kind === 'slash_command'
|
||||
const prefix = isSlashCommand ? '/' : '@'
|
||||
const tokenWithSpaces = ` ${prefix}${c.label} `
|
||||
const tokenAtStart = `${prefix}${c.label} `
|
||||
// Token can appear with leading space OR at the start of the message
|
||||
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart)
|
||||
const tokenPattern = new RegExp(
|
||||
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`
|
||||
)
|
||||
return tokenPattern.test(message)
|
||||
})
|
||||
return filtered.length === prev.length ? prev : filtered
|
||||
})
|
||||
|
||||
@@ -76,6 +76,15 @@ export function useMentionTokens({
|
||||
ranges.push({ start: idx, end: idx + token.length, label })
|
||||
fromIndex = idx + token.length
|
||||
}
|
||||
|
||||
// Token at end of message without trailing space: "@label" or " /label"
|
||||
const tokenAtEnd = `${prefix}${label}`
|
||||
if (message.endsWith(tokenAtEnd)) {
|
||||
const idx = message.lastIndexOf(tokenAtEnd)
|
||||
const hasLeadingSpace = idx > 0 && message[idx - 1] === ' '
|
||||
const start = hasLeadingSpace ? idx - 1 : idx
|
||||
ranges.push({ start, end: message.length, label })
|
||||
}
|
||||
}
|
||||
|
||||
ranges.sort((a, b) => a.start - b.start)
|
||||
|
||||
@@ -613,7 +613,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const insertTriggerAndOpenMenu = useCallback(
|
||||
(trigger: '@' | '/') => {
|
||||
if (disabled || isLoading) return
|
||||
if (disabled) return
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
@@ -642,7 +642,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
},
|
||||
[disabled, isLoading, mentionMenu, message, setMessage]
|
||||
[disabled, mentionMenu, message, setMessage]
|
||||
)
|
||||
|
||||
const handleOpenMentionMenuWithAt = useCallback(
|
||||
@@ -737,7 +737,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
title='Insert @'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] p-[4.5px]',
|
||||
(disabled || isLoading) && 'cursor-not-allowed'
|
||||
disabled && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<AtSign className='h-3 w-3' strokeWidth={1.75} />
|
||||
@@ -749,7 +749,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
title='Insert /'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] p-[4.5px]',
|
||||
(disabled || isLoading) && 'cursor-not-allowed'
|
||||
disabled && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
|
||||
@@ -816,7 +816,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
placeholder={fileAttachments.isDragging ? 'Drop files here...' : effectivePlaceholder}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
className='relative z-[2] m-0 box-border h-auto min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
|
||||
className='relative z-[2] m-0 box-border h-auto max-h-[120px] min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
|
||||
/>
|
||||
|
||||
{/* Mention Menu Portal */}
|
||||
|
||||
@@ -83,8 +83,7 @@ interface A2aDeployProps {
|
||||
workflowNeedsRedeployment?: boolean
|
||||
onSubmittingChange?: (submitting: boolean) => void
|
||||
onCanSaveChange?: (canSave: boolean) => void
|
||||
onAgentExistsChange?: (exists: boolean) => void
|
||||
onPublishedChange?: (published: boolean) => void
|
||||
/** Callback for when republish status changes - depends on local form state */
|
||||
onNeedsRepublishChange?: (needsRepublish: boolean) => void
|
||||
onDeployWorkflow?: () => Promise<void>
|
||||
}
|
||||
@@ -99,8 +98,6 @@ export function A2aDeploy({
|
||||
workflowNeedsRedeployment,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
onAgentExistsChange,
|
||||
onPublishedChange,
|
||||
onNeedsRepublishChange,
|
||||
onDeployWorkflow,
|
||||
}: A2aDeployProps) {
|
||||
@@ -236,14 +233,6 @@ export function A2aDeploy({
|
||||
}
|
||||
}, [existingAgent, workflowName, workflowDescription])
|
||||
|
||||
useEffect(() => {
|
||||
onAgentExistsChange?.(!!existingAgent)
|
||||
}, [existingAgent, onAgentExistsChange])
|
||||
|
||||
useEffect(() => {
|
||||
onPublishedChange?.(existingAgent?.isPublished ?? false)
|
||||
}, [existingAgent?.isPublished, onPublishedChange])
|
||||
|
||||
const hasFormChanges = useMemo(() => {
|
||||
if (!existingAgent) return false
|
||||
const savedSchemes = existingAgent.authentication?.schemes || []
|
||||
|
||||
@@ -29,9 +29,11 @@ import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
|
||||
import {
|
||||
type AuthType,
|
||||
type ChatFormData,
|
||||
useChatDeployment,
|
||||
useIdentifierValidation,
|
||||
} from './hooks'
|
||||
useCreateChat,
|
||||
useDeleteChat,
|
||||
useUpdateChat,
|
||||
} from '@/hooks/queries/chats'
|
||||
import { useIdentifierValidation } from './hooks'
|
||||
|
||||
const logger = createLogger('ChatDeploy')
|
||||
|
||||
@@ -45,7 +47,6 @@ interface ChatDeployProps {
|
||||
existingChat: ExistingChat | null
|
||||
isLoadingChat: boolean
|
||||
onRefetchChat: () => Promise<void>
|
||||
onChatExistsChange?: (exists: boolean) => void
|
||||
chatSubmitting: boolean
|
||||
setChatSubmitting: (submitting: boolean) => void
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
@@ -97,7 +98,6 @@ export function ChatDeploy({
|
||||
existingChat,
|
||||
isLoadingChat,
|
||||
onRefetchChat,
|
||||
onChatExistsChange,
|
||||
chatSubmitting,
|
||||
setChatSubmitting,
|
||||
onValidationChange,
|
||||
@@ -121,8 +121,11 @@ export function ChatDeploy({
|
||||
|
||||
const [formData, setFormData] = useState<ChatFormData>(initialFormData)
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const { deployChat } = useChatDeployment()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
const createChatMutation = useCreateChat()
|
||||
const updateChatMutation = useUpdateChat()
|
||||
const deleteChatMutation = useDeleteChat()
|
||||
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
|
||||
const [hasInitializedForm, setHasInitializedForm] = useState(false)
|
||||
|
||||
@@ -231,15 +234,26 @@ export function ChatDeploy({
|
||||
return
|
||||
}
|
||||
|
||||
const chatUrl = await deployChat(
|
||||
workflowId,
|
||||
formData,
|
||||
deploymentInfo,
|
||||
existingChat?.id,
|
||||
imageUrl
|
||||
)
|
||||
let chatUrl: string
|
||||
|
||||
if (existingChat?.id) {
|
||||
const result = await updateChatMutation.mutateAsync({
|
||||
chatId: existingChat.id,
|
||||
workflowId,
|
||||
formData,
|
||||
imageUrl,
|
||||
})
|
||||
chatUrl = result.chatUrl
|
||||
} else {
|
||||
const result = await createChatMutation.mutateAsync({
|
||||
workflowId,
|
||||
formData,
|
||||
apiKey: deploymentInfo?.apiKey,
|
||||
imageUrl,
|
||||
})
|
||||
chatUrl = result.chatUrl
|
||||
}
|
||||
|
||||
onChatExistsChange?.(true)
|
||||
onDeployed?.()
|
||||
onVersionActivated?.()
|
||||
|
||||
@@ -266,18 +280,13 @@ export function ChatDeploy({
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
|
||||
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
|
||||
method: 'DELETE',
|
||||
await deleteChatMutation.mutateAsync({
|
||||
chatId: existingChat.id,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete chat')
|
||||
}
|
||||
|
||||
setImageUrl(null)
|
||||
setHasInitializedForm(false)
|
||||
onChatExistsChange?.(false)
|
||||
await onRefetchChat()
|
||||
|
||||
onDeploymentComplete?.()
|
||||
@@ -548,7 +557,7 @@ function IdentifierInput({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
|
||||
{error && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{error}</p>}
|
||||
<p className='mt-[6.5px] truncate text-[11px] text-[var(--text-secondary)]'>
|
||||
{isEditingExisting && value ? (
|
||||
<>
|
||||
@@ -768,7 +777,7 @@ function AuthSelector({
|
||||
disabled={disabled}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{emailError}</p>
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{emailError}</p>
|
||||
)}
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
{authType === 'email'
|
||||
@@ -778,7 +787,7 @@ function AuthSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
|
||||
{error && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
|
||||
export { useIdentifierValidation } from './use-identifier-validation'
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { z } from 'zod'
|
||||
import type { OutputConfig } from '@/stores/chat/types'
|
||||
|
||||
const logger = createLogger('ChatDeployment')
|
||||
|
||||
export type AuthType = 'public' | 'password' | 'email' | 'sso'
|
||||
|
||||
export interface ChatFormData {
|
||||
identifier: string
|
||||
title: string
|
||||
description: string
|
||||
authType: AuthType
|
||||
password: string
|
||||
emails: string[]
|
||||
welcomeMessage: string
|
||||
selectedOutputBlocks: string[]
|
||||
}
|
||||
|
||||
const chatSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
identifier: z
|
||||
.string()
|
||||
.min(1, 'Identifier is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
customizations: z.object({
|
||||
primaryColor: z.string(),
|
||||
welcomeMessage: z.string(),
|
||||
imageUrl: z.string().optional(),
|
||||
}),
|
||||
authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
|
||||
password: z.string().optional(),
|
||||
allowedEmails: z.array(z.string()).optional().default([]),
|
||||
outputConfigs: z
|
||||
.array(
|
||||
z.object({
|
||||
blockId: z.string(),
|
||||
path: z.string(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
|
||||
/**
|
||||
* Parses output block selections into structured output configs
|
||||
*/
|
||||
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
|
||||
return selectedOutputBlocks
|
||||
.map((outputId) => {
|
||||
const firstUnderscoreIndex = outputId.indexOf('_')
|
||||
if (firstUnderscoreIndex !== -1) {
|
||||
const blockId = outputId.substring(0, firstUnderscoreIndex)
|
||||
const path = outputId.substring(firstUnderscoreIndex + 1)
|
||||
if (blockId && path) {
|
||||
return { blockId, path }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((config): config is OutputConfig => config !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for deploying or updating a chat interface
|
||||
*/
|
||||
export function useChatDeployment() {
|
||||
const deployChat = useCallback(
|
||||
async (
|
||||
workflowId: string,
|
||||
formData: ChatFormData,
|
||||
deploymentInfo: { apiKey: string } | null,
|
||||
existingChatId?: string,
|
||||
imageUrl?: string | null
|
||||
): Promise<string> => {
|
||||
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
|
||||
|
||||
const payload = {
|
||||
workflowId,
|
||||
identifier: formData.identifier.trim(),
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim(),
|
||||
customizations: {
|
||||
primaryColor: 'var(--brand-primary-hover-hex)',
|
||||
welcomeMessage: formData.welcomeMessage.trim(),
|
||||
...(imageUrl && { imageUrl }),
|
||||
},
|
||||
authType: formData.authType,
|
||||
password: formData.authType === 'password' ? formData.password : undefined,
|
||||
allowedEmails:
|
||||
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
|
||||
outputConfigs,
|
||||
apiKey: deploymentInfo?.apiKey,
|
||||
deployApiEnabled: !existingChatId,
|
||||
}
|
||||
|
||||
chatSchema.parse(payload)
|
||||
|
||||
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
|
||||
const method = existingChatId ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (result.error === 'Identifier already in use') {
|
||||
throw new Error('This identifier is already in use')
|
||||
}
|
||||
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
|
||||
}
|
||||
|
||||
if (!result.chatUrl) {
|
||||
throw new Error('Response missing chatUrl')
|
||||
}
|
||||
|
||||
logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
|
||||
return result.chatUrl
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { deployChat }
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export function FormBuilder({
|
||||
)}
|
||||
</div>
|
||||
{titleError && (
|
||||
<p className='mt-[4px] text-[11px] text-[var(--text-error)]'>{titleError}</p>
|
||||
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>{titleError}</p>
|
||||
)}
|
||||
<div className='mt-[4px] flex items-center gap-[6px]'>
|
||||
<input
|
||||
|
||||
@@ -17,11 +17,18 @@ import { Skeleton } from '@/components/ui'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import {
|
||||
type FieldConfig,
|
||||
useCreateForm,
|
||||
useDeleteForm,
|
||||
useFormByWorkflow,
|
||||
useUpdateForm,
|
||||
} from '@/hooks/queries/forms'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { EmbedCodeGenerator } from './components/embed-code-generator'
|
||||
import { FormBuilder } from './components/form-builder'
|
||||
import { useFormDeployment } from './hooks/use-form-deployment'
|
||||
import { useIdentifierValidation } from './hooks/use-identifier-validation'
|
||||
|
||||
const logger = createLogger('FormDeploy')
|
||||
@@ -34,38 +41,11 @@ interface FormErrors {
|
||||
general?: string
|
||||
}
|
||||
|
||||
interface FieldConfig {
|
||||
name: string
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export interface ExistingForm {
|
||||
id: string
|
||||
identifier: string
|
||||
title: string
|
||||
description?: string
|
||||
customizations: {
|
||||
primaryColor?: string
|
||||
thankYouMessage?: string
|
||||
logoUrl?: string
|
||||
fieldConfigs?: FieldConfig[]
|
||||
}
|
||||
authType: 'public' | 'password' | 'email'
|
||||
hasPassword?: boolean
|
||||
allowedEmails?: string[]
|
||||
showBranding: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface FormDeployProps {
|
||||
workflowId: string
|
||||
onDeploymentComplete?: () => void
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
onSubmittingChange?: (isSubmitting: boolean) => void
|
||||
onExistingFormChange?: (exists: boolean) => void
|
||||
formSubmitting?: boolean
|
||||
setFormSubmitting?: (submitting: boolean) => void
|
||||
onDeployed?: () => Promise<void>
|
||||
@@ -81,7 +61,6 @@ export function FormDeploy({
|
||||
onDeploymentComplete,
|
||||
onValidationChange,
|
||||
onSubmittingChange,
|
||||
onExistingFormChange,
|
||||
formSubmitting,
|
||||
setFormSubmitting,
|
||||
onDeployed,
|
||||
@@ -95,8 +74,6 @@ export function FormDeploy({
|
||||
const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public')
|
||||
const [password, setPassword] = useState('')
|
||||
const [emailItems, setEmailItems] = useState<TagItem[]>([])
|
||||
const [existingForm, setExistingForm] = useState<ExistingForm | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [formUrl, setFormUrl] = useState('')
|
||||
const [inputFields, setInputFields] = useState<{ name: string; type: string }[]>([])
|
||||
const [showPasswordField, setShowPasswordField] = useState(false)
|
||||
@@ -104,7 +81,12 @@ export function FormDeploy({
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
|
||||
|
||||
const { createForm, updateForm, deleteForm, isSubmitting } = useFormDeployment()
|
||||
const { data: existingForm, isLoading } = useFormByWorkflow(workflowId)
|
||||
const createFormMutation = useCreateForm()
|
||||
const updateFormMutation = useUpdateForm()
|
||||
const deleteFormMutation = useDeleteForm()
|
||||
|
||||
const isSubmitting = createFormMutation.isPending || updateFormMutation.isPending
|
||||
|
||||
const {
|
||||
isChecking: isCheckingIdentifier,
|
||||
@@ -124,85 +106,54 @@ export function FormDeploy({
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }))
|
||||
}
|
||||
|
||||
// Fetch existing form deployment
|
||||
// Populate form fields when existing form data is loaded
|
||||
useEffect(() => {
|
||||
async function fetchExistingForm() {
|
||||
if (!workflowId) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await fetch(`/api/workflows/${workflowId}/form/status`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.isDeployed && data.form) {
|
||||
const detailResponse = await fetch(`/api/form/manage/${data.form.id}`)
|
||||
if (detailResponse.ok) {
|
||||
const formDetail = await detailResponse.json()
|
||||
const form = formDetail.form as ExistingForm
|
||||
setExistingForm(form)
|
||||
onExistingFormChange?.(true)
|
||||
|
||||
setIdentifier(form.identifier)
|
||||
setTitle(form.title)
|
||||
setDescription(form.description || '')
|
||||
setThankYouMessage(
|
||||
form.customizations?.thankYouMessage ||
|
||||
'Your response has been submitted successfully.'
|
||||
)
|
||||
setAuthType(form.authType)
|
||||
setEmailItems(
|
||||
(form.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
|
||||
)
|
||||
if (form.customizations?.fieldConfigs) {
|
||||
setFieldConfigs(form.customizations.fieldConfigs)
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
let host = url.host
|
||||
if (host.startsWith('www.')) host = host.substring(4)
|
||||
setFormUrl(`${url.protocol}//${host}/form/${form.identifier}`)
|
||||
} catch {
|
||||
setFormUrl(
|
||||
isDev
|
||||
? `http://localhost:3000/form/${form.identifier}`
|
||||
: `https://sim.ai/form/${form.identifier}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setExistingForm(null)
|
||||
onExistingFormChange?.(false)
|
||||
|
||||
const workflowName =
|
||||
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
|
||||
?.name || 'Form'
|
||||
setTitle(`${workflowName} Form`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching form deployment:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (existingForm) {
|
||||
setIdentifier(existingForm.identifier)
|
||||
setTitle(existingForm.title)
|
||||
setDescription(existingForm.description || '')
|
||||
setThankYouMessage(
|
||||
existingForm.customizations?.thankYouMessage ||
|
||||
'Your response has been submitted successfully.'
|
||||
)
|
||||
setAuthType(existingForm.authType)
|
||||
setEmailItems(
|
||||
(existingForm.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
|
||||
)
|
||||
if (existingForm.customizations?.fieldConfigs) {
|
||||
setFieldConfigs(existingForm.customizations.fieldConfigs)
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
let host = url.host
|
||||
if (host.startsWith('www.')) host = host.substring(4)
|
||||
setFormUrl(`${url.protocol}//${host}/form/${existingForm.identifier}`)
|
||||
} catch {
|
||||
setFormUrl(
|
||||
isDev
|
||||
? `http://localhost:3000/form/${existingForm.identifier}`
|
||||
: `https://sim.ai/form/${existingForm.identifier}`
|
||||
)
|
||||
}
|
||||
} else if (!isLoading) {
|
||||
const workflowName =
|
||||
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
|
||||
?.name || 'Form'
|
||||
setTitle(`${workflowName} Form`)
|
||||
}
|
||||
}, [existingForm, isLoading])
|
||||
|
||||
fetchExistingForm()
|
||||
}, [workflowId, onExistingFormChange])
|
||||
|
||||
// Get input fields from start block and initialize field configs
|
||||
useEffect(() => {
|
||||
const blocks = Object.values(useWorkflowStore.getState().blocks)
|
||||
const startBlock = blocks.find((b) => b.type === 'starter' || b.type === 'start_trigger')
|
||||
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
|
||||
|
||||
if (startBlock) {
|
||||
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')
|
||||
if (inputFormat && Array.isArray(inputFormat)) {
|
||||
setInputFields(inputFormat)
|
||||
|
||||
// Initialize field configs if not already set
|
||||
if (fieldConfigs.length === 0) {
|
||||
setFieldConfigs(
|
||||
inputFormat.map((f: { name: string; type?: string }) => ({
|
||||
@@ -222,7 +173,6 @@ export function FormDeploy({
|
||||
|
||||
const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
|
||||
|
||||
// Validate form
|
||||
useEffect(() => {
|
||||
const isValid =
|
||||
inputFields.length > 0 &&
|
||||
@@ -253,7 +203,6 @@ export function FormDeploy({
|
||||
e.preventDefault()
|
||||
setErrors({})
|
||||
|
||||
// Validate before submit
|
||||
if (!isIdentifierValid && identifier !== existingForm?.identifier) {
|
||||
setError('identifier', 'Please wait for identifier validation to complete')
|
||||
return
|
||||
@@ -281,17 +230,21 @@ export function FormDeploy({
|
||||
|
||||
try {
|
||||
if (existingForm) {
|
||||
await updateForm(existingForm.id, {
|
||||
identifier,
|
||||
title,
|
||||
description,
|
||||
customizations,
|
||||
authType,
|
||||
password: password || undefined,
|
||||
allowedEmails,
|
||||
await updateFormMutation.mutateAsync({
|
||||
formId: existingForm.id,
|
||||
workflowId,
|
||||
data: {
|
||||
identifier,
|
||||
title,
|
||||
description,
|
||||
customizations,
|
||||
authType,
|
||||
password: password || undefined,
|
||||
allowedEmails,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const result = await createForm({
|
||||
const result = await createFormMutation.mutateAsync({
|
||||
workflowId,
|
||||
identifier,
|
||||
title,
|
||||
@@ -304,7 +257,6 @@ export function FormDeploy({
|
||||
|
||||
if (result?.formUrl) {
|
||||
setFormUrl(result.formUrl)
|
||||
// Open the form in a new window after successful deployment
|
||||
window.open(result.formUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
@@ -318,7 +270,6 @@ export function FormDeploy({
|
||||
const message = err instanceof Error ? err.message : 'An error occurred'
|
||||
logger.error('Error deploying form:', err)
|
||||
|
||||
// Parse error message and show inline
|
||||
if (message.toLowerCase().includes('identifier')) {
|
||||
setError('identifier', message)
|
||||
} else if (message.toLowerCase().includes('password')) {
|
||||
@@ -342,8 +293,8 @@ export function FormDeploy({
|
||||
password,
|
||||
allowedEmails,
|
||||
isIdentifierValid,
|
||||
createForm,
|
||||
updateForm,
|
||||
createFormMutation,
|
||||
updateFormMutation,
|
||||
onDeployed,
|
||||
onDeploymentComplete,
|
||||
]
|
||||
@@ -353,9 +304,10 @@ export function FormDeploy({
|
||||
if (!existingForm) return
|
||||
|
||||
try {
|
||||
await deleteForm(existingForm.id)
|
||||
setExistingForm(null)
|
||||
onExistingFormChange?.(false)
|
||||
await deleteFormMutation.mutateAsync({
|
||||
formId: existingForm.id,
|
||||
workflowId,
|
||||
})
|
||||
setIdentifier('')
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
@@ -363,7 +315,7 @@ export function FormDeploy({
|
||||
} catch (err) {
|
||||
logger.error('Error deleting form:', err)
|
||||
}
|
||||
}, [existingForm, deleteForm, onExistingFormChange])
|
||||
}, [existingForm, deleteFormMutation, workflowId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -447,7 +399,7 @@ export function FormDeploy({
|
||||
</div>
|
||||
</div>
|
||||
{(identifierError || errors.identifier) && (
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
|
||||
{identifierError || errors.identifier}
|
||||
</p>
|
||||
)}
|
||||
@@ -531,7 +483,7 @@ export function FormDeploy({
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.password}</p>
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.password}</p>
|
||||
)}
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
{existingForm?.hasPassword
|
||||
@@ -568,7 +520,7 @@ export function FormDeploy({
|
||||
placeholderWithTags='Add another'
|
||||
/>
|
||||
{errors.emails && (
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.emails}</p>
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.emails}</p>
|
||||
)}
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
Add specific emails or entire domains (@example.com)
|
||||
@@ -599,7 +551,7 @@ export function FormDeploy({
|
||||
)}
|
||||
|
||||
{errors.general && (
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.general}</p>
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.general}</p>
|
||||
)}
|
||||
|
||||
<button type='button' data-delete-trigger onClick={handleDelete} className='hidden' />
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('useFormDeployment')
|
||||
|
||||
interface CreateFormParams {
|
||||
workflowId: string
|
||||
identifier: string
|
||||
title: string
|
||||
description?: string
|
||||
customizations?: {
|
||||
primaryColor?: string
|
||||
welcomeMessage?: string
|
||||
thankYouTitle?: string
|
||||
thankYouMessage?: string
|
||||
logoUrl?: string
|
||||
}
|
||||
authType?: 'public' | 'password' | 'email'
|
||||
password?: string
|
||||
allowedEmails?: string[]
|
||||
showBranding?: boolean
|
||||
}
|
||||
|
||||
interface UpdateFormParams {
|
||||
identifier?: string
|
||||
title?: string
|
||||
description?: string
|
||||
customizations?: {
|
||||
primaryColor?: string
|
||||
welcomeMessage?: string
|
||||
thankYouTitle?: string
|
||||
thankYouMessage?: string
|
||||
logoUrl?: string
|
||||
}
|
||||
authType?: 'public' | 'password' | 'email'
|
||||
password?: string
|
||||
allowedEmails?: string[]
|
||||
showBranding?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface CreateFormResult {
|
||||
id: string
|
||||
formUrl: string
|
||||
}
|
||||
|
||||
export function useFormDeployment() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createForm = useCallback(
|
||||
async (params: CreateFormParams): Promise<CreateFormResult | null> => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/form', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create form')
|
||||
}
|
||||
|
||||
logger.info('Form created successfully:', { id: data.id })
|
||||
return {
|
||||
id: data.id,
|
||||
formUrl: data.formUrl,
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to create form'
|
||||
setError(errorMessage)
|
||||
logger.error('Error creating form:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateForm = useCallback(async (formId: string, params: UpdateFormParams) => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/form/manage/${formId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update form')
|
||||
}
|
||||
|
||||
logger.info('Form updated successfully:', { id: formId })
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to update form'
|
||||
setError(errorMessage)
|
||||
logger.error('Error updating form:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteForm = useCallback(async (formId: string) => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/form/manage/${formId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete form')
|
||||
}
|
||||
|
||||
logger.info('Form deleted successfully:', { id: formId })
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to delete form'
|
||||
setError(errorMessage)
|
||||
logger.error('Error deleting form:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
createForm,
|
||||
updateForm,
|
||||
deleteForm,
|
||||
isSubmitting,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
@@ -43,7 +43,6 @@ interface McpDeployProps {
|
||||
onAddedToServer?: () => void
|
||||
onSubmittingChange?: (submitting: boolean) => void
|
||||
onCanSaveChange?: (canSave: boolean) => void
|
||||
onHasServersChange?: (hasServers: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +91,6 @@ export function McpDeploy({
|
||||
onAddedToServer,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
onHasServersChange,
|
||||
}: McpDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -257,10 +255,6 @@ export function McpDeploy({
|
||||
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
|
||||
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
|
||||
|
||||
useEffect(() => {
|
||||
onHasServersChange?.(servers.length > 0)
|
||||
}, [servers.length, onHasServersChange])
|
||||
|
||||
/**
|
||||
* Save tool configuration to all deployed servers
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
|
||||
import {
|
||||
useCreateTemplate,
|
||||
useDeleteTemplate,
|
||||
@@ -47,26 +48,11 @@ const initialFormData: TemplateFormData = {
|
||||
tags: [],
|
||||
}
|
||||
|
||||
interface CreatorOption {
|
||||
id: string
|
||||
name: string
|
||||
referenceType: 'user' | 'organization'
|
||||
referenceId: string
|
||||
}
|
||||
|
||||
interface TemplateStatus {
|
||||
status: 'pending' | 'approved' | 'rejected' | null
|
||||
views?: number
|
||||
stars?: number
|
||||
}
|
||||
|
||||
interface TemplateDeployProps {
|
||||
workflowId: string
|
||||
onDeploymentComplete?: () => void
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
onSubmittingChange?: (isSubmitting: boolean) => void
|
||||
onExistingTemplateChange?: (exists: boolean) => void
|
||||
onTemplateStatusChange?: (status: TemplateStatus | null) => void
|
||||
}
|
||||
|
||||
export function TemplateDeploy({
|
||||
@@ -74,13 +60,9 @@ export function TemplateDeploy({
|
||||
onDeploymentComplete,
|
||||
onValidationChange,
|
||||
onSubmittingChange,
|
||||
onExistingTemplateChange,
|
||||
onTemplateStatusChange,
|
||||
}: TemplateDeployProps) {
|
||||
const { data: session } = useSession()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
|
||||
const [loadingCreators, setLoadingCreators] = useState(false)
|
||||
const [isCapturing, setIsCapturing] = useState(false)
|
||||
const previewContainerRef = useRef<HTMLDivElement>(null)
|
||||
const ogCaptureRef = useRef<HTMLDivElement>(null)
|
||||
@@ -88,6 +70,7 @@ export function TemplateDeploy({
|
||||
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
|
||||
|
||||
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
|
||||
const { data: creatorProfiles = [], isLoading: loadingCreators } = useCreatorProfiles()
|
||||
const createMutation = useCreateTemplate()
|
||||
const updateMutation = useUpdateTemplate()
|
||||
const deleteMutation = useDeleteTemplate()
|
||||
@@ -112,63 +95,15 @@ export function TemplateDeploy({
|
||||
}, [isSubmitting, onSubmittingChange])
|
||||
|
||||
useEffect(() => {
|
||||
onExistingTemplateChange?.(!!existingTemplate)
|
||||
}, [existingTemplate, onExistingTemplateChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (existingTemplate) {
|
||||
onTemplateStatusChange?.({
|
||||
status: existingTemplate.status as 'pending' | 'approved' | 'rejected',
|
||||
views: existingTemplate.views,
|
||||
stars: existingTemplate.stars,
|
||||
})
|
||||
} else {
|
||||
onTemplateStatusChange?.(null)
|
||||
if (creatorProfiles.length === 1 && !formData.creatorId) {
|
||||
updateField('creatorId', creatorProfiles[0].id)
|
||||
logger.info('Auto-selected single creator profile:', creatorProfiles[0].name)
|
||||
}
|
||||
}, [existingTemplate, onTemplateStatusChange])
|
||||
|
||||
const fetchCreatorOptions = async () => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
setLoadingCreators(true)
|
||||
try {
|
||||
const response = await fetch('/api/creators')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const profiles = (data.profiles || []).map((profile: any) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
referenceType: profile.referenceType,
|
||||
referenceId: profile.referenceId,
|
||||
}))
|
||||
setCreatorOptions(profiles)
|
||||
return profiles
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching creator profiles:', error)
|
||||
} finally {
|
||||
setLoadingCreators(false)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}, [creatorProfiles, formData.creatorId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCreatorOptions()
|
||||
}, [session?.user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (creatorOptions.length === 1 && !formData.creatorId) {
|
||||
updateField('creatorId', creatorOptions[0].id)
|
||||
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
|
||||
}
|
||||
}, [creatorOptions, formData.creatorId])
|
||||
|
||||
useEffect(() => {
|
||||
const handleCreatorProfileSaved = async () => {
|
||||
logger.info('Creator profile saved, refreshing profiles...')
|
||||
|
||||
await fetchCreatorOptions()
|
||||
|
||||
const handleCreatorProfileSaved = () => {
|
||||
logger.info('Creator profile saved, reopening deploy modal...')
|
||||
window.dispatchEvent(new CustomEvent('close-settings'))
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
|
||||
@@ -357,7 +292,7 @@ export function TemplateDeploy({
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Creator <span className='text-[var(--text-error)]'>*</span>
|
||||
</Label>
|
||||
{creatorOptions.length === 0 && !loadingCreators ? (
|
||||
{creatorProfiles.length === 0 && !loadingCreators ? (
|
||||
<div className='space-y-[8px]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
A creator profile is required to publish templates.
|
||||
@@ -385,9 +320,9 @@ export function TemplateDeploy({
|
||||
</div>
|
||||
) : (
|
||||
<Combobox
|
||||
options={creatorOptions.map((option) => ({
|
||||
label: option.name,
|
||||
value: option.id,
|
||||
options={creatorProfiles.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: profile.id,
|
||||
}))}
|
||||
value={formData.creatorId}
|
||||
selectedValue={formData.creatorId}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -17,11 +18,22 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
|
||||
import { startsWithUuid } from '@/executor/constants'
|
||||
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
|
||||
import { useApiKeys } from '@/hooks/queries/api-keys'
|
||||
import {
|
||||
deploymentKeys,
|
||||
useActivateDeploymentVersion,
|
||||
useChatDeploymentInfo,
|
||||
useDeploymentInfo,
|
||||
useDeploymentVersions,
|
||||
useDeployWorkflow,
|
||||
useUndeployWorkflow,
|
||||
} from '@/hooks/queries/deployments'
|
||||
import { useTemplateByWorkflow } from '@/hooks/queries/templates'
|
||||
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
@@ -48,7 +60,7 @@ interface DeployModalProps {
|
||||
refetchDeployedState: () => Promise<void>
|
||||
}
|
||||
|
||||
interface WorkflowDeploymentInfo {
|
||||
interface WorkflowDeploymentInfoUI {
|
||||
isDeployed: boolean
|
||||
deployedAt?: string
|
||||
apiKey: string
|
||||
@@ -69,16 +81,12 @@ export function DeployModal({
|
||||
isLoadingDeployedState,
|
||||
refetchDeployedState,
|
||||
}: DeployModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
|
||||
const deploymentStatus = useWorkflowRegistry((state) =>
|
||||
state.getWorkflowDeploymentStatus(workflowId)
|
||||
)
|
||||
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
|
||||
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isUndeploying, setIsUndeploying] = useState(false)
|
||||
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const workflowMetadata = useWorkflowRegistry((state) =>
|
||||
workflowId ? state.workflows[workflowId] : undefined
|
||||
)
|
||||
@@ -86,33 +94,18 @@ export function DeployModal({
|
||||
const [activeTab, setActiveTab] = useState<TabView>('general')
|
||||
const [chatSubmitting, setChatSubmitting] = useState(false)
|
||||
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
|
||||
const [chatExists, setChatExists] = useState(false)
|
||||
const [isChatFormValid, setIsChatFormValid] = useState(false)
|
||||
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
|
||||
|
||||
const [versions, setVersions] = useState<WorkflowDeploymentVersionResponse[]>([])
|
||||
const [versionsLoading, setVersionsLoading] = useState(false)
|
||||
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
|
||||
const [templateFormValid, setTemplateFormValid] = useState(false)
|
||||
const [templateSubmitting, setTemplateSubmitting] = useState(false)
|
||||
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
|
||||
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
|
||||
const [hasMcpServers, setHasMcpServers] = useState(false)
|
||||
const [a2aSubmitting, setA2aSubmitting] = useState(false)
|
||||
const [a2aCanSave, setA2aCanSave] = useState(false)
|
||||
const [hasA2aAgent, setHasA2aAgent] = useState(false)
|
||||
const [isA2aPublished, setIsA2aPublished] = useState(false)
|
||||
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
|
||||
const [showA2aDeleteConfirm, setShowA2aDeleteConfirm] = useState(false)
|
||||
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
|
||||
const [templateStatus, setTemplateStatus] = useState<{
|
||||
status: 'pending' | 'approved' | 'rejected' | null
|
||||
views?: number
|
||||
stars?: number
|
||||
} | null>(null)
|
||||
|
||||
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
|
||||
const [isLoadingChat, setIsLoadingChat] = useState(false)
|
||||
|
||||
const [chatSuccess, setChatSuccess] = useState(false)
|
||||
|
||||
@@ -133,193 +126,107 @@ export function DeployModal({
|
||||
const createButtonDisabled =
|
||||
isApiKeysLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
|
||||
|
||||
const getApiKeyLabel = (value?: string | null) => {
|
||||
if (value && value.trim().length > 0) {
|
||||
return value
|
||||
}
|
||||
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
|
||||
}
|
||||
const {
|
||||
data: deploymentInfoData,
|
||||
isLoading: isLoadingDeploymentInfo,
|
||||
refetch: refetchDeploymentInfo,
|
||||
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
|
||||
|
||||
const getApiHeaderPlaceholder = () =>
|
||||
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
|
||||
const {
|
||||
data: versionsData,
|
||||
isLoading: versionsLoading,
|
||||
refetch: refetchVersions,
|
||||
} = useDeploymentVersions(workflowId, { enabled: open })
|
||||
|
||||
const getInputFormatExample = (includeStreaming = false) => {
|
||||
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
|
||||
}
|
||||
const {
|
||||
isLoading: isLoadingChat,
|
||||
chatExists,
|
||||
existingChat,
|
||||
refetch: refetchChatInfo,
|
||||
} = useChatDeploymentInfo(workflowId, { enabled: open })
|
||||
|
||||
const fetchChatDeploymentInfo = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '')
|
||||
const hasMcpServers = mcpServers.length > 0
|
||||
|
||||
try {
|
||||
setIsLoadingChat(true)
|
||||
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
|
||||
const { data: existingA2aAgent } = useA2AAgentByWorkflow(
|
||||
workflowWorkspaceId || '',
|
||||
workflowId || ''
|
||||
)
|
||||
const hasA2aAgent = !!existingA2aAgent
|
||||
const isA2aPublished = existingA2aAgent?.isPublished ?? false
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.isDeployed && data.deployment) {
|
||||
const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
|
||||
if (detailResponse.ok) {
|
||||
const chatDetail = await detailResponse.json()
|
||||
setExistingChat(chatDetail)
|
||||
setChatExists(true)
|
||||
} else {
|
||||
setExistingChat(null)
|
||||
setChatExists(false)
|
||||
}
|
||||
} else {
|
||||
setExistingChat(null)
|
||||
setChatExists(false)
|
||||
}
|
||||
} else {
|
||||
setExistingChat(null)
|
||||
setChatExists(false)
|
||||
const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', {
|
||||
enabled: !!workflowId,
|
||||
})
|
||||
const hasExistingTemplate = !!existingTemplate
|
||||
const templateStatus = existingTemplate
|
||||
? {
|
||||
status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null,
|
||||
views: existingTemplate.views,
|
||||
stars: existingTemplate.stars,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching chat deployment info:', { error })
|
||||
setExistingChat(null)
|
||||
setChatExists(false)
|
||||
} finally {
|
||||
setIsLoadingChat(false)
|
||||
: null
|
||||
|
||||
const deployMutation = useDeployWorkflow()
|
||||
const undeployMutation = useUndeployWorkflow()
|
||||
const activateVersionMutation = useActivateDeploymentVersion()
|
||||
|
||||
const versions = versionsData?.versions ?? []
|
||||
|
||||
const getApiKeyLabel = useCallback(
|
||||
(value?: string | null) => {
|
||||
if (value && value.trim().length > 0) {
|
||||
return value
|
||||
}
|
||||
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
|
||||
},
|
||||
[workflowWorkspaceId]
|
||||
)
|
||||
|
||||
const getApiHeaderPlaceholder = useCallback(
|
||||
() => (workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'),
|
||||
[workflowWorkspaceId]
|
||||
)
|
||||
|
||||
const getInputFormatExample = useCallback(
|
||||
(includeStreaming = false) => {
|
||||
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
|
||||
},
|
||||
[selectedStreamingOutputs]
|
||||
)
|
||||
|
||||
const deploymentInfo: WorkflowDeploymentInfoUI | null = useMemo(() => {
|
||||
if (!deploymentInfoData?.isDeployed || !workflowId) {
|
||||
return null
|
||||
}
|
||||
}, [workflowId])
|
||||
|
||||
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
const placeholderKey = getApiHeaderPlaceholder()
|
||||
|
||||
return {
|
||||
isDeployed: deploymentInfoData.isDeployed,
|
||||
deployedAt: deploymentInfoData.deployedAt ?? undefined,
|
||||
apiKey: getApiKeyLabel(deploymentInfoData.apiKey),
|
||||
endpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
|
||||
needsRedeployment: deploymentInfoData.needsRedeployment,
|
||||
}
|
||||
}, [
|
||||
deploymentInfoData,
|
||||
workflowId,
|
||||
selectedStreamingOutputs,
|
||||
getInputFormatExample,
|
||||
getApiHeaderPlaceholder,
|
||||
getApiKeyLabel,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
setActiveTab('general')
|
||||
setApiDeployError(null)
|
||||
fetchChatDeploymentInfo()
|
||||
}
|
||||
}, [open, workflowId, fetchChatDeploymentInfo])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDeploymentInfo() {
|
||||
if (!open || !workflowId || !isDeployed) {
|
||||
setDeploymentInfo(null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (deploymentInfo?.isDeployed && !needsRedeployment) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deployment information')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY'
|
||||
|
||||
setDeploymentInfo({
|
||||
isDeployed: data.isDeployed,
|
||||
deployedAt: data.deployedAt,
|
||||
apiKey: data.apiKey || placeholderKey,
|
||||
endpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
|
||||
needsRedeployment,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching deployment info:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDeploymentInfo()
|
||||
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
|
||||
|
||||
const onDeploy = async () => {
|
||||
setApiDeployError(null)
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to deploy workflow')
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
|
||||
const isDeployedStatus = responseData.isDeployed ?? false
|
||||
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
|
||||
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
|
||||
|
||||
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
|
||||
|
||||
if (workflowId) {
|
||||
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
|
||||
}
|
||||
|
||||
await refetchDeployedState()
|
||||
await fetchVersions()
|
||||
|
||||
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
|
||||
if (deploymentInfoResponse.ok) {
|
||||
const deploymentData = await deploymentInfoResponse.json()
|
||||
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
const placeholderKey = getApiHeaderPlaceholder()
|
||||
|
||||
setDeploymentInfo({
|
||||
isDeployed: deploymentData.isDeployed,
|
||||
deployedAt: deploymentData.deployedAt,
|
||||
apiKey: getApiKeyLabel(deploymentData.apiKey),
|
||||
endpoint: apiEndpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
|
||||
needsRedeployment: false,
|
||||
})
|
||||
}
|
||||
|
||||
setApiDeployError(null)
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error deploying workflow:', { error })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
|
||||
setApiDeployError(errorMessage)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVersions = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${workflowId}/deployments`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVersions(Array.isArray(data.versions) ? data.versions : [])
|
||||
} else {
|
||||
setVersions([])
|
||||
}
|
||||
} catch {
|
||||
setVersions([])
|
||||
}
|
||||
}, [workflowId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
setVersionsLoading(true)
|
||||
fetchVersions().finally(() => setVersionsLoading(false))
|
||||
}
|
||||
}, [open, workflowId, fetchVersions])
|
||||
}, [open, workflowId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || selectedStreamingOutputs.length === 0) return
|
||||
@@ -369,181 +276,88 @@ export function DeployModal({
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
const onDeploy = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
setApiDeployError(null)
|
||||
|
||||
try {
|
||||
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
|
||||
await refetchDeployedState()
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error deploying workflow:', { error })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
|
||||
setApiDeployError(errorMessage)
|
||||
}
|
||||
}, [workflowId, deployMutation, refetchDeployedState])
|
||||
|
||||
const handlePromoteToLive = useCallback(
|
||||
async (version: number) => {
|
||||
if (!workflowId) return
|
||||
|
||||
const previousVersions = [...versions]
|
||||
setVersions((prev) =>
|
||||
prev.map((v) => ({
|
||||
...v,
|
||||
isActive: v.version === version,
|
||||
}))
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workflows/${workflowId}/deployments/${version}/activate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to promote version')
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
|
||||
const deployedAtTime = responseData.deployedAt
|
||||
? new Date(responseData.deployedAt)
|
||||
: undefined
|
||||
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
|
||||
|
||||
setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
|
||||
|
||||
refetchDeployedState()
|
||||
fetchVersions()
|
||||
|
||||
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
|
||||
if (deploymentInfoResponse.ok) {
|
||||
const deploymentData = await deploymentInfoResponse.json()
|
||||
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
const placeholderKey = getApiHeaderPlaceholder()
|
||||
|
||||
setDeploymentInfo({
|
||||
isDeployed: deploymentData.isDeployed,
|
||||
deployedAt: deploymentData.deployedAt,
|
||||
apiKey: getApiKeyLabel(deploymentData.apiKey),
|
||||
endpoint: apiEndpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
|
||||
needsRedeployment: false,
|
||||
})
|
||||
}
|
||||
await activateVersionMutation.mutateAsync({ workflowId, version })
|
||||
await refetchDeployedState()
|
||||
} catch (error) {
|
||||
setVersions(previousVersions)
|
||||
logger.error('Error promoting version:', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[workflowId, versions, refetchDeployedState, fetchVersions, selectedStreamingOutputs]
|
||||
[workflowId, activateVersionMutation, refetchDeployedState]
|
||||
)
|
||||
|
||||
const handleUndeploy = async () => {
|
||||
const handleUndeploy = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
try {
|
||||
setIsUndeploying(true)
|
||||
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to undeploy workflow')
|
||||
}
|
||||
|
||||
setDeploymentStatus(workflowId, false)
|
||||
setChatExists(false)
|
||||
await undeployMutation.mutateAsync({ workflowId })
|
||||
setShowUndeployConfirm(false)
|
||||
onOpenChange(false)
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error undeploying workflow:', { error })
|
||||
} finally {
|
||||
setIsUndeploying(false)
|
||||
}
|
||||
}
|
||||
}, [workflowId, undeployMutation, onOpenChange])
|
||||
|
||||
const handleRedeploy = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
setApiDeployError(null)
|
||||
|
||||
const handleRedeploy = async () => {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to redeploy workflow')
|
||||
}
|
||||
|
||||
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
|
||||
|
||||
setDeploymentStatus(
|
||||
workflowId,
|
||||
newDeployStatus,
|
||||
deployedAt ? new Date(deployedAt) : undefined,
|
||||
getApiKeyLabel(apiKey)
|
||||
)
|
||||
|
||||
if (workflowId) {
|
||||
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
|
||||
}
|
||||
|
||||
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
|
||||
await refetchDeployedState()
|
||||
await fetchVersions()
|
||||
|
||||
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error redeploying workflow:', { error })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow'
|
||||
setApiDeployError(errorMessage)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
}, [workflowId, deployMutation, refetchDeployedState])
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsSubmitting(false)
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setChatSubmitting(false)
|
||||
setApiDeployError(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleChatDeployed = async () => {
|
||||
await handlePostDeploymentUpdate()
|
||||
setChatSuccess(true)
|
||||
setTimeout(() => setChatSuccess(false), 2000)
|
||||
}
|
||||
|
||||
const handlePostDeploymentUpdate = async () => {
|
||||
const handleChatDeployed = useCallback(async () => {
|
||||
if (!workflowId) return
|
||||
|
||||
setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
|
||||
|
||||
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
|
||||
if (deploymentInfoResponse.ok) {
|
||||
const deploymentData = await deploymentInfoResponse.json()
|
||||
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
|
||||
const placeholderKey = getApiHeaderPlaceholder()
|
||||
|
||||
setDeploymentInfo({
|
||||
isDeployed: deploymentData.isDeployed,
|
||||
deployedAt: deploymentData.deployedAt,
|
||||
apiKey: getApiKeyLabel(deploymentData.apiKey),
|
||||
endpoint: apiEndpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
|
||||
needsRedeployment: false,
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) })
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) })
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(workflowId) })
|
||||
|
||||
await refetchDeployedState()
|
||||
await fetchVersions()
|
||||
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
|
||||
}
|
||||
|
||||
const handleChatFormSubmit = () => {
|
||||
setChatSuccess(true)
|
||||
setTimeout(() => setChatSuccess(false), 2000)
|
||||
}, [workflowId, queryClient, refetchDeployedState])
|
||||
|
||||
const handleRefetchChat = useCallback(async () => {
|
||||
await refetchChatInfo()
|
||||
}, [refetchChatInfo])
|
||||
|
||||
const handleChatFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement
|
||||
@@ -553,9 +367,9 @@ export function DeployModal({
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChatDelete = () => {
|
||||
const handleChatDelete = useCallback(() => {
|
||||
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
const deleteButton = form.querySelector('[data-delete-trigger]') as HTMLButtonElement
|
||||
@@ -563,7 +377,7 @@ export function DeployModal({
|
||||
deleteButton.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTemplateFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('template-deploy-form') as HTMLFormElement
|
||||
@@ -623,6 +437,13 @@ export function DeployModal({
|
||||
deleteTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleFetchVersions = useCallback(async () => {
|
||||
await refetchVersions()
|
||||
}, [refetchVersions])
|
||||
|
||||
const isSubmitting = deployMutation.isPending
|
||||
const isUndeploying = undeployMutation.isPending
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleCloseModal}>
|
||||
@@ -670,7 +491,7 @@ export function DeployModal({
|
||||
versionsLoading={versionsLoading}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
fetchVersions={fetchVersions}
|
||||
fetchVersions={handleFetchVersions}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
@@ -678,7 +499,7 @@ export function DeployModal({
|
||||
<ApiDeploy
|
||||
workflowId={workflowId}
|
||||
deploymentInfo={deploymentInfo}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoadingDeploymentInfo}
|
||||
needsRedeployment={needsRedeployment}
|
||||
apiDeployError={apiDeployError}
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
@@ -691,10 +512,9 @@ export function DeployModal({
|
||||
<ChatDeploy
|
||||
workflowId={workflowId || ''}
|
||||
deploymentInfo={deploymentInfo}
|
||||
existingChat={existingChat}
|
||||
existingChat={existingChat as ExistingChat | null}
|
||||
isLoadingChat={isLoadingChat}
|
||||
onRefetchChat={fetchChatDeploymentInfo}
|
||||
onChatExistsChange={setChatExists}
|
||||
onRefetchChat={handleRefetchChat}
|
||||
chatSubmitting={chatSubmitting}
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
@@ -711,8 +531,6 @@ export function DeployModal({
|
||||
onDeploymentComplete={handleCloseModal}
|
||||
onValidationChange={setTemplateFormValid}
|
||||
onSubmittingChange={setTemplateSubmitting}
|
||||
onExistingTemplateChange={setHasExistingTemplate}
|
||||
onTemplateStatusChange={setTemplateStatus}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
@@ -741,7 +559,6 @@ export function DeployModal({
|
||||
isDeployed={isDeployed}
|
||||
onSubmittingChange={setMcpToolSubmitting}
|
||||
onCanSaveChange={setMcpToolCanSave}
|
||||
onHasServersChange={setHasMcpServers}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
@@ -756,8 +573,6 @@ export function DeployModal({
|
||||
workflowNeedsRedeployment={needsRedeployment}
|
||||
onSubmittingChange={setA2aSubmitting}
|
||||
onCanSaveChange={setA2aCanSave}
|
||||
onAgentExistsChange={setHasA2aAgent}
|
||||
onPublishedChange={setIsA2aPublished}
|
||||
onNeedsRepublishChange={setA2aNeedsRepublish}
|
||||
onDeployWorkflow={onDeploy}
|
||||
/>
|
||||
@@ -843,7 +658,7 @@ export function DeployModal({
|
||||
onClick={handleMcpToolFormSubmit}
|
||||
disabled={mcpToolSubmitting || !mcpToolCanSave}
|
||||
>
|
||||
{mcpToolSubmitting ? 'Saving...' : 'Save Tool Schema'}
|
||||
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -2,16 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Constants for ComboBox component behavior
|
||||
@@ -91,15 +94,24 @@ export function ComboBox({
|
||||
// Dependency tracking for fetchOptions
|
||||
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
const dependencyValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
|
||||
return dependsOnFields.map((depKey) =>
|
||||
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId]
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
@@ -308,6 +308,7 @@ export function OAuthRequiredModal({
|
||||
serviceId,
|
||||
newScopes = [],
|
||||
}: OAuthRequiredModalProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { baseProvider } = parseProvider(provider)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
|
||||
@@ -348,23 +349,24 @@ export function OAuthRequiredModal({
|
||||
}, [requiredScopes, newScopesSet])
|
||||
|
||||
const handleConnectDirectly = async () => {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const providerId = getProviderIdFromServiceId(serviceId)
|
||||
|
||||
onClose()
|
||||
|
||||
logger.info('Linking OAuth2:', {
|
||||
providerId,
|
||||
requiredScopes,
|
||||
})
|
||||
|
||||
if (providerId === 'trello') {
|
||||
onClose()
|
||||
window.location.href = '/api/auth/trello/authorize'
|
||||
return
|
||||
}
|
||||
|
||||
if (providerId === 'shopify') {
|
||||
// Pass the current URL so we can redirect back after OAuth
|
||||
onClose()
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
|
||||
return
|
||||
@@ -374,8 +376,10 @@ export function OAuthRequiredModal({
|
||||
providerId,
|
||||
callbackURL: window.location.href,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error initiating OAuth flow:', { error })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
logger.error('Error initiating OAuth flow:', { error: err })
|
||||
setError('Failed to connect. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,10 +429,12 @@ export function OAuthRequiredModal({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={onClose}>
|
||||
<Button variant='default' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' type='button' onClick={handleConnectDirectly}>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Dropdown option type - can be a simple string or an object with label, id, and optional icon
|
||||
@@ -89,15 +92,24 @@ export function Dropdown({
|
||||
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
const dependencyValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
|
||||
return dependsOnFields.map((depKey) =>
|
||||
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId]
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -4,15 +4,19 @@ import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { isDependency } from '@/blocks/utils'
|
||||
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -42,21 +46,59 @@ export function FileSelectorInput({
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
|
||||
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const blockValues = useSubBlockStore((state) => {
|
||||
if (!activeWorkflowId) return {}
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
|
||||
})
|
||||
|
||||
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
|
||||
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
|
||||
|
||||
const teamIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.teamId ??
|
||||
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const siteIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.siteId ??
|
||||
resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.siteId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const collectionIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.collectionId ??
|
||||
resolveDependencyValue('collectionId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.collectionId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const projectIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.projectId ??
|
||||
resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.projectId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const planIdValue = useMemo(
|
||||
() =>
|
||||
previewContextValues?.planId ??
|
||||
resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.planId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -65,7 +107,6 @@ export function FileSelectorInput({
|
||||
? ((connectedCredential as Record<string, any>).id ?? '')
|
||||
: ''
|
||||
|
||||
// Derive provider from serviceId using OAuth config (same pattern as credential-selector)
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface ProjectSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -32,21 +35,36 @@ export function ProjectSelectorInput({
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: ProjectSelectorInputProps) {
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const params = useParams()
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
|
||||
|
||||
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
const blockValues = useSubBlockStore((state) => {
|
||||
if (!activeWorkflowId) return {}
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
|
||||
})
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
|
||||
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
|
||||
|
||||
// Derive provider from serviceId using OAuth config
|
||||
const linearTeamId = useMemo(
|
||||
() =>
|
||||
previewContextValues?.teamId ??
|
||||
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
|
||||
@@ -54,7 +72,6 @@ export function ProjectSelectorInput({
|
||||
effectiveProviderId,
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
@@ -62,12 +79,8 @@ export function ProjectSelectorInput({
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
// Jira/Discord upstream fields - use values from previewContextValues or store
|
||||
const domain = (jiraDomain as string) || ''
|
||||
|
||||
// Verify Jira credential belongs to current user; if not, treat as absent
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedProjectId(previewValue)
|
||||
|
||||
@@ -4,14 +4,17 @@ import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface SheetSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -41,16 +44,32 @@ export function SheetSelectorInput({
|
||||
previewContextValues,
|
||||
})
|
||||
|
||||
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
|
||||
const [spreadsheetIdFromStore] = useSubBlockValue(blockId, 'spreadsheetId')
|
||||
const [manualSpreadsheetIdFromStore] = useSubBlockValue(blockId, 'manualSpreadsheetId')
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
const blockValues = useSubBlockStore((state) => {
|
||||
if (!activeWorkflowId) return {}
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
|
||||
})
|
||||
|
||||
const connectedCredentialFromStore = blockValues.credential
|
||||
|
||||
const spreadsheetIdFromStore = useMemo(
|
||||
() =>
|
||||
resolveDependencyValue('spreadsheetId', blockValues, canonicalIndex, canonicalModeOverrides),
|
||||
[blockValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const spreadsheetId =
|
||||
previewContextValues?.spreadsheetId ??
|
||||
spreadsheetIdFromStore ??
|
||||
previewContextValues?.manualSpreadsheetId ??
|
||||
manualSpreadsheetIdFromStore
|
||||
const spreadsheetId = previewContextValues
|
||||
? (previewContextValues.spreadsheetId ?? previewContextValues.manualSpreadsheetId)
|
||||
: spreadsheetIdFromStore
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -61,7 +80,6 @@ export function SheetSelectorInput({
|
||||
|
||||
const normalizedSpreadsheetId = typeof spreadsheetId === 'string' ? spreadsheetId.trim() : ''
|
||||
|
||||
// Derive provider from serviceId using OAuth config
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AlertCircle, Wand2 } from 'lucide-react'
|
||||
import { AlertCircle, ArrowUp } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -878,35 +879,53 @@ try {
|
||||
JSON Schema
|
||||
</Label>
|
||||
{schemaError && (
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='truncate'>{schemaError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
|
||||
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
|
||||
{!isSchemaPromptActive ? (
|
||||
<button
|
||||
type='button'
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleSchemaWandClick}
|
||||
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
|
||||
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
|
||||
aria-label='Generate schema with AI'
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
|
||||
</button>
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<input
|
||||
ref={schemaPromptInputRef}
|
||||
type='text'
|
||||
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
|
||||
onChange={(e) => handleSchemaPromptChange(e.target.value)}
|
||||
onBlur={handleSchemaPromptBlur}
|
||||
onKeyDown={handleSchemaPromptKeyDown}
|
||||
disabled={schemaGeneration.isStreaming}
|
||||
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
|
||||
placeholder='Describe schema...'
|
||||
/>
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={schemaPromptInputRef}
|
||||
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
|
||||
onChange={(e) => handleSchemaPromptChange(e.target.value)}
|
||||
onBlur={handleSchemaPromptBlur}
|
||||
onKeyDown={handleSchemaPromptKeyDown}
|
||||
disabled={schemaGeneration.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
schemaGeneration.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!schemaPromptInput.trim() || schemaGeneration.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSchemaPromptSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -952,35 +971,53 @@ try {
|
||||
Code
|
||||
</Label>
|
||||
{codeError && !codeGeneration.isStreaming && (
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
|
||||
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
|
||||
<AlertCircle className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='truncate'>{codeError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
|
||||
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
|
||||
{!isCodePromptActive ? (
|
||||
<button
|
||||
type='button'
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleCodeWandClick}
|
||||
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
|
||||
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
|
||||
aria-label='Generate code with AI'
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
|
||||
</button>
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<input
|
||||
ref={codePromptInputRef}
|
||||
type='text'
|
||||
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
|
||||
onChange={(e) => handleCodePromptChange(e.target.value)}
|
||||
onBlur={handleCodePromptBlur}
|
||||
onKeyDown={handleCodePromptKeyDown}
|
||||
disabled={codeGeneration.isStreaming}
|
||||
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
|
||||
placeholder='Describe code...'
|
||||
/>
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={codePromptInputRef}
|
||||
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
|
||||
onChange={(e) => handleCodePromptChange(e.target.value)}
|
||||
onBlur={handleCodePromptBlur}
|
||||
onKeyDown={handleCodePromptKeyDown}
|
||||
disabled={codeGeneration.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
codeGeneration.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!codePromptInput.trim() || codeGeneration.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCodePromptSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -557,7 +557,7 @@ function FileUploadSyncWrapper({
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelSelectorSyncWrapper({
|
||||
function SlackSelectorSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
@@ -565,6 +565,7 @@ function ChannelSelectorSyncWrapper({
|
||||
uiComponent,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
selectorType,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
@@ -573,6 +574,7 @@ function ChannelSelectorSyncWrapper({
|
||||
uiComponent: any
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
selectorType: 'channel-selector' | 'user-selector'
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
@@ -580,7 +582,7 @@ function ChannelSelectorSyncWrapper({
|
||||
blockId={blockId}
|
||||
subBlock={{
|
||||
id: paramId,
|
||||
type: 'channel-selector' as const,
|
||||
type: selectorType,
|
||||
title: paramId,
|
||||
serviceId: uiComponent.serviceId,
|
||||
placeholder: uiComponent.placeholder,
|
||||
@@ -1952,7 +1954,7 @@ export function ToolInput({
|
||||
|
||||
case 'channel-selector':
|
||||
return (
|
||||
<ChannelSelectorSyncWrapper
|
||||
<SlackSelectorSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
@@ -1960,6 +1962,21 @@ export function ToolInput({
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams as any}
|
||||
selectorType='channel-selector'
|
||||
/>
|
||||
)
|
||||
|
||||
case 'user-selector':
|
||||
return (
|
||||
<SlackSelectorSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams as any}
|
||||
selectorType='user-selector'
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
isNonEmptyValue,
|
||||
resolveDependencyValue,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
type DependsOnConfig = string[] | { all?: string[]; any?: string[] }
|
||||
|
||||
@@ -50,6 +57,13 @@ export function useDependsOnGate(
|
||||
const previewContextValues = opts?.previewContextValues
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||
|
||||
// Parse dependsOn config to get all/any field lists
|
||||
const { allFields, anyFields, allDependsOnFields } = useMemo(
|
||||
@@ -91,7 +105,13 @@ export function useDependsOnGate(
|
||||
if (previewContextValues) {
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
map[key] = normalizeDependencyValue(previewContextValues[key])
|
||||
const resolvedValue = resolveDependencyValue(
|
||||
key,
|
||||
previewContextValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
map[key] = normalizeDependencyValue(resolvedValue)
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -108,32 +128,25 @@ export function useDependsOnGate(
|
||||
const blockValues = (workflowValues as any)[blockId] || {}
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
map[key] = normalizeDependencyValue((blockValues as any)[key])
|
||||
const resolvedValue = resolveDependencyValue(
|
||||
key,
|
||||
blockValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
map[key] = normalizeDependencyValue(resolvedValue)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// For backward compatibility, also provide array of values
|
||||
const dependencyValues = useMemo(
|
||||
() => allDependsOnFields.map((key) => dependencyValuesMap[key]),
|
||||
[allDependsOnFields, dependencyValuesMap]
|
||||
) as any[]
|
||||
|
||||
const isValueSatisfied = (value: unknown): boolean => {
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
return value !== ''
|
||||
}
|
||||
|
||||
const depsSatisfied = useMemo(() => {
|
||||
// Check all fields (AND logic) - all must be satisfied
|
||||
const allSatisfied =
|
||||
allFields.length === 0 || allFields.every((key) => isValueSatisfied(dependencyValuesMap[key]))
|
||||
allFields.length === 0 || allFields.every((key) => isNonEmptyValue(dependencyValuesMap[key]))
|
||||
|
||||
// Check any fields (OR logic) - at least one must be satisfied
|
||||
const anySatisfied =
|
||||
anyFields.length === 0 || anyFields.some((key) => isValueSatisfied(dependencyValuesMap[key]))
|
||||
anyFields.length === 0 || anyFields.some((key) => isNonEmptyValue(dependencyValuesMap[key]))
|
||||
|
||||
return allSatisfied && anySatisfied
|
||||
}, [allFields, anyFields, dependencyValuesMap])
|
||||
@@ -146,7 +159,6 @@ export function useDependsOnGate(
|
||||
|
||||
return {
|
||||
dependsOn,
|
||||
dependencyValues,
|
||||
depsSatisfied,
|
||||
blocked,
|
||||
finalDisabled,
|
||||
|
||||
@@ -210,9 +210,11 @@ export function useSubBlockValue<T = any>(
|
||||
)
|
||||
|
||||
// Determine the effective value: diff value takes precedence if in diff mode
|
||||
// Use nullish coalescing to fall back to initialValue when storeValue is null or undefined
|
||||
// This handles the case where a block is newly added and the subblock store hasn't been populated yet
|
||||
const effectiveValue = hasSnapshotValue ? snapshotValue : (storeValue ?? initialValue)
|
||||
const effectiveValue = hasSnapshotValue
|
||||
? snapshotValue
|
||||
: storeValue !== undefined
|
||||
? storeValue
|
||||
: initialValue
|
||||
|
||||
// Initialize valueRef on first render
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, ArrowUp } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeftRight, ArrowUp } from 'lucide-react'
|
||||
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
|
||||
@@ -67,6 +67,11 @@ interface SubBlockProps {
|
||||
disabled?: boolean
|
||||
fieldDiffStatus?: FieldDiffStatus
|
||||
allowExpandInPreview?: boolean
|
||||
canonicalToggle?: {
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +187,11 @@ const renderLabel = (
|
||||
onSearchSubmit: () => void
|
||||
onSearchCancel: () => void
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
},
|
||||
canonicalToggle?: {
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
): JSX.Element | null => {
|
||||
if (config.type === 'switch') return null
|
||||
@@ -189,13 +199,12 @@ const renderLabel = (
|
||||
|
||||
const required = isFieldRequired(config, subBlockValues)
|
||||
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
|
||||
const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview
|
||||
const canonicalToggleDisabled = wandState?.disabled || canonicalToggle?.disabled
|
||||
|
||||
return (
|
||||
<Label
|
||||
className='flex items-center justify-between gap-[6px] pl-[2px]'
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{config.type === 'code' && config.language === 'json' && (
|
||||
@@ -213,58 +222,82 @@ const renderLabel = (
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
{showWand && (
|
||||
<>
|
||||
{!wandState.isSearchActive ? (
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={wandState.onSearchClick}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={wandState.searchInputRef}
|
||||
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
|
||||
onChange={(e) => wandState.onSearchChange(e.target.value)}
|
||||
onBlur={wandState.onSearchBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && wandState.searchQuery.trim() && !wandState.isStreaming) {
|
||||
wandState.onSearchSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
wandState.onSearchCancel()
|
||||
}
|
||||
}}
|
||||
disabled={wandState.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
wandState.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
</Label>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
{showWand && (
|
||||
<>
|
||||
{!wandState.isSearchActive ? (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
wandState.onSearchSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={wandState.onSearchClick}
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
) : (
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={wandState.searchInputRef}
|
||||
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
|
||||
onChange={(e) => wandState.onSearchChange(e.target.value)}
|
||||
onBlur={wandState.onSearchBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
wandState.searchQuery.trim() &&
|
||||
!wandState.isStreaming
|
||||
) {
|
||||
wandState.onSearchSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
wandState.onSearchCancel()
|
||||
}
|
||||
}}
|
||||
disabled={wandState.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
wandState.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate with AI...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
wandState.onSearchSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showCanonicalToggle && (
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
onClick={canonicalToggle?.onToggle}
|
||||
disabled={canonicalToggleDisabled}
|
||||
aria-label={canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
className={cn(
|
||||
'!h-[12px] !w-[12px]',
|
||||
canonicalToggle?.mode === 'advanced'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -287,7 +320,9 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
|
||||
prevProps.subBlockValues === nextProps.subBlockValues &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
||||
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
|
||||
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,6 +351,7 @@ function SubBlockComponent({
|
||||
disabled = false,
|
||||
fieldDiffStatus,
|
||||
allowExpandInPreview,
|
||||
canonicalToggle,
|
||||
}: SubBlockProps): JSX.Element {
|
||||
const [isValidJson, setIsValidJson] = useState(true)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
@@ -887,20 +923,26 @@ function SubBlockComponent({
|
||||
|
||||
return (
|
||||
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
|
||||
{renderLabel(config, isValidJson, subBlockValues, {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
disabled: isDisabled,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
})}
|
||||
{renderLabel(
|
||||
config,
|
||||
isValidJson,
|
||||
subBlockValues,
|
||||
{
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
disabled: isDisabled,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
},
|
||||
canonicalToggle
|
||||
)}
|
||||
{renderInput()}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, Check, ChevronUp, Pencil, Settings } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
hasAdvancedValues,
|
||||
hasStandaloneAdvancedFields,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
ConnectionBlocks,
|
||||
@@ -20,6 +27,7 @@ import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockType } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -89,17 +97,65 @@ export function Editor() {
|
||||
)
|
||||
)
|
||||
|
||||
const subBlocksForCanonical = useMemo(() => {
|
||||
const subBlocks = blockConfig?.subBlocks || []
|
||||
if (!triggerMode) return subBlocks
|
||||
return subBlocks.filter(
|
||||
(subBlock) =>
|
||||
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
|
||||
)
|
||||
}, [blockConfig?.subBlocks, triggerMode])
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(subBlocksForCanonical),
|
||||
[subBlocksForCanonical]
|
||||
)
|
||||
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
|
||||
const advancedValuesPresent = hasAdvancedValues(
|
||||
subBlocksForCanonical,
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
)
|
||||
const displayAdvancedOptions = advancedMode || advancedValuesPresent
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(
|
||||
() => hasStandaloneAdvancedFields(subBlocksForCanonical, canonicalIndex),
|
||||
[subBlocksForCanonical, canonicalIndex]
|
||||
)
|
||||
|
||||
// Get subblock layout using custom hook
|
||||
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
|
||||
blockConfig || ({} as any),
|
||||
currentBlockId || '',
|
||||
advancedMode,
|
||||
displayAdvancedOptions,
|
||||
triggerMode,
|
||||
activeWorkflowId,
|
||||
blockSubBlockValues,
|
||||
currentWorkflow.isSnapshotView
|
||||
)
|
||||
|
||||
/**
|
||||
* Partitions subBlocks into regular fields and standalone advanced-only fields.
|
||||
* Standalone advanced fields have mode 'advanced' and are not part of a canonical swap pair.
|
||||
*/
|
||||
const { regularSubBlocks, advancedOnlySubBlocks } = useMemo(() => {
|
||||
const regular: typeof subBlocks = []
|
||||
const advancedOnly: typeof subBlocks = []
|
||||
|
||||
for (const subBlock of subBlocks) {
|
||||
const isStandaloneAdvanced =
|
||||
subBlock.mode === 'advanced' && !canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
|
||||
|
||||
if (isStandaloneAdvanced) {
|
||||
advancedOnly.push(subBlock)
|
||||
} else {
|
||||
regular.push(subBlock)
|
||||
}
|
||||
}
|
||||
|
||||
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
|
||||
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
|
||||
|
||||
// Get block connections
|
||||
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
|
||||
|
||||
@@ -109,21 +165,23 @@ export function Editor() {
|
||||
})
|
||||
|
||||
// Collaborative actions
|
||||
const { collaborativeToggleBlockAdvancedMode, collaborativeUpdateBlockName } =
|
||||
useCollaborativeWorkflow()
|
||||
const {
|
||||
collaborativeSetBlockCanonicalMode,
|
||||
collaborativeUpdateBlockName,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Advanced mode toggle handler
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (!currentBlockId || !userPermissions.canEdit) return
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
// Rename state
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Mode toggle handlers
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (currentBlockId && userPermissions.canEdit) {
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
/**
|
||||
* Handles starting the rename process.
|
||||
*/
|
||||
@@ -183,9 +241,6 @@ export function Editor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if block has advanced mode or trigger mode available
|
||||
const hasAdvancedMode = blockConfig?.subBlocks?.some((sb) => sb.mode === 'advanced')
|
||||
|
||||
// Determine if connections are at minimum height (collapsed state)
|
||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||
|
||||
@@ -278,25 +333,6 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)} */}
|
||||
{/* Mode toggles - Only show for regular blocks, not subflows */}
|
||||
{currentBlock && !isSubflow && hasAdvancedMode && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleToggleAdvancedMode}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label='Toggle advanced mode'
|
||||
>
|
||||
<Settings className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Advanced mode</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -342,14 +378,111 @@ export function Editor() {
|
||||
ref={subBlocksRef}
|
||||
className='subblocks-section flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px]'>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
|
||||
{subBlocks.length === 0 ? (
|
||||
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
|
||||
This block has no subblocks
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col'>
|
||||
{subBlocks.map((subBlock, index) => {
|
||||
{regularSubBlocks.map((subBlock, index) => {
|
||||
const stableKey = getSubBlockStableKey(
|
||||
currentBlockId || '',
|
||||
subBlock,
|
||||
subBlockState
|
||||
)
|
||||
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
|
||||
const canonicalGroup = canonicalId
|
||||
? canonicalIndex.groupsById[canonicalId]
|
||||
: undefined
|
||||
const isCanonicalSwap = isCanonicalPair(canonicalGroup)
|
||||
const canonicalMode =
|
||||
canonicalGroup && isCanonicalSwap
|
||||
? resolveCanonicalMode(
|
||||
canonicalGroup,
|
||||
blockSubBlockValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
: undefined
|
||||
|
||||
const showDivider =
|
||||
index < regularSubBlocks.length - 1 ||
|
||||
(!hasAdvancedOnlyFields && index < subBlocks.length - 1)
|
||||
|
||||
return (
|
||||
<div key={stableKey} className='subblock-row'>
|
||||
<SubBlock
|
||||
blockId={currentBlockId}
|
||||
config={subBlock}
|
||||
isPreview={false}
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!userPermissions.canEdit}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
canonicalToggle={
|
||||
isCanonicalSwap && canonicalMode && canonicalId
|
||||
? {
|
||||
mode: canonicalMode,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onToggle: () => {
|
||||
if (!currentBlockId) return
|
||||
const nextMode =
|
||||
canonicalMode === 'advanced' ? 'basic' : 'advanced'
|
||||
collaborativeSetBlockCanonicalMode(
|
||||
currentBlockId,
|
||||
canonicalId,
|
||||
nextMode
|
||||
)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showDivider && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasAdvancedOnlyFields && userPermissions.canEdit && (
|
||||
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||
<div
|
||||
className='h-[1.25px] flex-1'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggleAdvancedMode}
|
||||
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{displayAdvancedOptions ? 'Hide advanced fields' : 'Show advanced fields'}
|
||||
<ChevronDown
|
||||
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className='h-[1.25px] flex-1'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{advancedOnlySubBlocks.map((subBlock, index) => {
|
||||
const stableKey = getSubBlockStableKey(
|
||||
currentBlockId || '',
|
||||
subBlock,
|
||||
@@ -367,7 +500,7 @@ export function Editor() {
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
/>
|
||||
{index < subBlocks.length - 1 && (
|
||||
{index < advancedOnlySubBlocks.length - 1 && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
@@ -27,6 +32,10 @@ export function useEditorSubblockLayout(
|
||||
blockSubBlockValues: Record<string, any>,
|
||||
isSnapshotView: boolean
|
||||
) {
|
||||
const blockDataFromStore = useWorkflowStore(
|
||||
useCallback((state) => state.blocks?.[blockId]?.data, [blockId])
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
// Guard against missing config or block selection
|
||||
if (!config || !Array.isArray((config as any).subBlocks) || !blockId) {
|
||||
@@ -46,6 +55,7 @@ export function useEditorSubblockLayout(
|
||||
|
||||
const mergedState = mergedMap ? mergedMap[blockId] : undefined
|
||||
const mergedSubBlocks = mergedState?.subBlocks || {}
|
||||
const blockData = isSnapshotView ? mergedState?.data || {} : blockDataFromStore || {}
|
||||
|
||||
const stateToUse = Object.keys(mergedSubBlocks).reduce(
|
||||
(acc, key) => {
|
||||
@@ -69,13 +79,29 @@ export function useEditorSubblockLayout(
|
||||
}
|
||||
|
||||
// Filter visible blocks and those that meet their conditions
|
||||
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const subBlocksForCanonical = displayTriggerMode
|
||||
? (config.subBlocks || []).filter(
|
||||
(subBlock) =>
|
||||
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
|
||||
)
|
||||
: config.subBlocks || []
|
||||
const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical)
|
||||
const effectiveAdvanced = displayAdvancedMode
|
||||
const canonicalModeOverrides = blockData?.canonicalModes
|
||||
|
||||
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
|
||||
if (block.hidden) return false
|
||||
|
||||
// Check required feature if specified - declarative feature gating
|
||||
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
|
||||
return false
|
||||
}
|
||||
if (!isSubBlockFeatureEnabled(block)) return false
|
||||
|
||||
// Special handling for trigger-config type (legacy trigger configuration UI)
|
||||
if (block.type === ('trigger-config' as SubBlockType)) {
|
||||
@@ -84,13 +110,8 @@ export function useEditorSubblockLayout(
|
||||
}
|
||||
|
||||
// Filter by mode if specified
|
||||
if (block.mode) {
|
||||
if (block.mode === 'basic' && displayAdvancedMode) return false
|
||||
if (block.mode === 'advanced' && !displayAdvancedMode) return false
|
||||
if (block.mode === 'trigger') {
|
||||
// Show trigger mode blocks only when in trigger mode
|
||||
if (!displayTriggerMode) return false
|
||||
}
|
||||
if (block.mode === 'trigger') {
|
||||
if (!displayTriggerMode) return false
|
||||
}
|
||||
|
||||
// When in trigger mode, hide blocks that don't have mode: 'trigger'
|
||||
@@ -98,42 +119,22 @@ export function useEditorSubblockLayout(
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
block,
|
||||
effectiveAdvanced,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If there's no condition, the block should be shown
|
||||
if (!block.condition) return true
|
||||
|
||||
// If condition is a function, call it to get the actual condition object
|
||||
const actualCondition =
|
||||
typeof block.condition === 'function' ? block.condition() : block.condition
|
||||
|
||||
// Get the values of the fields this block depends on from the appropriate state
|
||||
const fieldValue = stateToUse[actualCondition.field]?.value
|
||||
const andFieldValue = actualCondition.and
|
||||
? stateToUse[actualCondition.and.field]?.value
|
||||
: undefined
|
||||
|
||||
// Check if the condition value is an array
|
||||
const isValueMatch = Array.isArray(actualCondition.value)
|
||||
? fieldValue != null &&
|
||||
(actualCondition.not
|
||||
? !actualCondition.value.includes(fieldValue as string | number | boolean)
|
||||
: actualCondition.value.includes(fieldValue as string | number | boolean))
|
||||
: actualCondition.not
|
||||
? fieldValue !== actualCondition.value
|
||||
: fieldValue === actualCondition.value
|
||||
|
||||
// Check both conditions if 'and' is present
|
||||
const isAndValueMatch =
|
||||
!actualCondition.and ||
|
||||
(Array.isArray(actualCondition.and.value)
|
||||
? andFieldValue != null &&
|
||||
(actualCondition.and.not
|
||||
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
|
||||
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
|
||||
: actualCondition.and.not
|
||||
? andFieldValue !== actualCondition.and.value
|
||||
: andFieldValue === actualCondition.and.value)
|
||||
|
||||
return isValueMatch && isAndValueMatch
|
||||
return evaluateSubBlockCondition(block.condition, rawValues)
|
||||
})
|
||||
|
||||
return { subBlocks: visibleSubBlocks, stateToUse }
|
||||
@@ -147,5 +148,6 @@ export function useEditorSubblockLayout(
|
||||
blockSubBlockValues,
|
||||
activeWorkflowId,
|
||||
isSnapshotView,
|
||||
blockDataFromStore,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -556,14 +556,17 @@ export function Panel() {
|
||||
<ModalHeader>Delete Workflow</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Deleting this workflow will permanently remove all associated blocks, executions, and
|
||||
configuration.{' '}
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{currentWorkflow?.name ?? 'this workflow'}
|
||||
</span>
|
||||
? This will permanently remove all associated blocks, executions, and configuration.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface SubflowNodeData {
|
||||
* @param props - Node properties containing data and id
|
||||
* @returns Rendered subflow node component
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
@@ -134,13 +134,15 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor) or preview selected - blue ring
|
||||
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
|
||||
* 2. Diff status (version comparison) - green/orange ring
|
||||
*/
|
||||
const hasRing = isFocused || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
|
||||
const isSelected = !isPreview && selected
|
||||
const hasRing =
|
||||
isFocused || isSelected || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
||||
(isFocused || isSelected || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
||||
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
|
||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||
)
|
||||
@@ -167,7 +169,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
data-node-id={id}
|
||||
data-type='subflowNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
data-subflow-selected={isFocused || isPreviewSelected}
|
||||
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
|
||||
>
|
||||
{!isPreview && (
|
||||
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
|
||||
|
||||
@@ -3,11 +3,18 @@ import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { Badge, Tooltip } from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
hasAdvancedValues,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
resolveDependencyValue,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import {
|
||||
@@ -201,7 +208,6 @@ const tryParseJson = (value: unknown): unknown => {
|
||||
export const getDisplayValue = (value: unknown): string => {
|
||||
if (value == null || value === '') return '-'
|
||||
|
||||
// Try parsing JSON strings first
|
||||
const parsedValue = tryParseJson(value)
|
||||
|
||||
if (isMessagesArray(parsedValue)) {
|
||||
@@ -330,6 +336,9 @@ const SubBlockRow = ({
|
||||
workflowId,
|
||||
blockId,
|
||||
allSubBlockValues,
|
||||
displayAdvancedOptions,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides,
|
||||
}: {
|
||||
title: string
|
||||
value?: string
|
||||
@@ -339,6 +348,9 @@ const SubBlockRow = ({
|
||||
workflowId?: string
|
||||
blockId?: string
|
||||
allSubBlockValues?: Record<string, { value: unknown }>
|
||||
displayAdvancedOptions?: boolean
|
||||
canonicalIndex?: ReturnType<typeof buildCanonicalIndex>
|
||||
canonicalModeOverrides?: Record<string, 'basic' | 'advanced'>
|
||||
}) => {
|
||||
const getStringValue = useCallback(
|
||||
(key?: string): string | undefined => {
|
||||
@@ -349,17 +361,43 @@ const SubBlockRow = ({
|
||||
[allSubBlockValues]
|
||||
)
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
if (!allSubBlockValues) return {}
|
||||
return Object.entries(allSubBlockValues).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
}, [allSubBlockValues])
|
||||
|
||||
const dependencyValues = useMemo(() => {
|
||||
const fields = getDependsOnFields(subBlock?.dependsOn)
|
||||
if (!fields.length) return {}
|
||||
return fields.reduce<Record<string, string>>((accumulator, dependency) => {
|
||||
const dependencyValue = getStringValue(dependency)
|
||||
if (dependencyValue) {
|
||||
accumulator[dependency] = dependencyValue
|
||||
const dependencyValue = resolveDependencyValue(
|
||||
dependency,
|
||||
rawValues,
|
||||
canonicalIndex || buildCanonicalIndex([]),
|
||||
canonicalModeOverrides
|
||||
)
|
||||
const dependencyString =
|
||||
typeof dependencyValue === 'string' && dependencyValue.length > 0
|
||||
? dependencyValue
|
||||
: undefined
|
||||
if (dependencyString) {
|
||||
accumulator[dependency] = dependencyString
|
||||
}
|
||||
return accumulator
|
||||
}, {})
|
||||
}, [getStringValue, subBlock?.dependsOn])
|
||||
}, [
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides,
|
||||
displayAdvancedOptions,
|
||||
rawValues,
|
||||
subBlock?.dependsOn,
|
||||
])
|
||||
|
||||
const credentialSourceId =
|
||||
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
|
||||
@@ -518,6 +556,7 @@ const SubBlockRow = ({
|
||||
export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<WorkflowBlockProps>) {
|
||||
const { type, config, name, isPending } = data
|
||||
|
||||
@@ -535,7 +574,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
hasRing,
|
||||
ringStyles,
|
||||
runPathStatus,
|
||||
} = useBlockVisual({ blockId: id, data, isPending })
|
||||
} = useBlockVisual({ blockId: id, data, isPending, isSelected: selected })
|
||||
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
|
||||
@@ -583,6 +622,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployChildWorkflow()
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const currentStoreBlock = currentWorkflow.getBlockById(id)
|
||||
|
||||
const isStarterBlock = type === 'starter'
|
||||
@@ -601,6 +642,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
[activeWorkflowId, id]
|
||||
)
|
||||
)
|
||||
const canonicalIndex = useMemo(() => buildCanonicalIndex(config.subBlocks), [config.subBlocks])
|
||||
const canonicalModeOverrides = currentStoreBlock?.data?.canonicalModes
|
||||
|
||||
const subBlockRowsData = useMemo(() => {
|
||||
const rows: SubBlockConfig[][] = []
|
||||
@@ -623,16 +666,23 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
{} as Record<string, { value: unknown }>
|
||||
)
|
||||
|
||||
const effectiveAdvanced = displayAdvancedMode
|
||||
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const effectiveAdvanced = userPermissions.canEdit
|
||||
? displayAdvancedMode
|
||||
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
|
||||
const effectiveTrigger = displayTriggerMode
|
||||
|
||||
const visibleSubBlocks = config.subBlocks.filter((block) => {
|
||||
if (block.hidden) return false
|
||||
if (block.hideFromPreview) return false
|
||||
|
||||
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
|
||||
return false
|
||||
}
|
||||
if (!isSubBlockFeatureEnabled(block)) return false
|
||||
|
||||
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
|
||||
|
||||
@@ -650,40 +700,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
}
|
||||
}
|
||||
|
||||
if (block.mode === 'basic' && effectiveAdvanced) return false
|
||||
if (block.mode === 'advanced' && !effectiveAdvanced) return false
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
block,
|
||||
effectiveAdvanced,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!block.condition) return true
|
||||
|
||||
const actualCondition =
|
||||
typeof block.condition === 'function' ? block.condition() : block.condition
|
||||
|
||||
const fieldValue = stateToUse[actualCondition.field]?.value
|
||||
const andFieldValue = actualCondition.and
|
||||
? stateToUse[actualCondition.and.field]?.value
|
||||
: undefined
|
||||
|
||||
const isValueMatch = Array.isArray(actualCondition.value)
|
||||
? fieldValue != null &&
|
||||
(actualCondition.not
|
||||
? !actualCondition.value.includes(fieldValue as string | number | boolean)
|
||||
: actualCondition.value.includes(fieldValue as string | number | boolean))
|
||||
: actualCondition.not
|
||||
? fieldValue !== actualCondition.value
|
||||
: fieldValue === actualCondition.value
|
||||
|
||||
const isAndValueMatch =
|
||||
!actualCondition.and ||
|
||||
(Array.isArray(actualCondition.and.value)
|
||||
? andFieldValue != null &&
|
||||
(actualCondition.and.not
|
||||
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
|
||||
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
|
||||
: actualCondition.and.not
|
||||
? andFieldValue !== actualCondition.and.value
|
||||
: andFieldValue === actualCondition.and.value)
|
||||
|
||||
return isValueMatch && isAndValueMatch
|
||||
return evaluateSubBlockCondition(block.condition, rawValues)
|
||||
})
|
||||
|
||||
visibleSubBlocks.forEach((block) => {
|
||||
@@ -715,12 +746,33 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
data.subBlockValues,
|
||||
currentWorkflow.isDiffMode,
|
||||
currentBlock,
|
||||
canonicalModeOverrides,
|
||||
userPermissions.canEdit,
|
||||
canonicalIndex,
|
||||
blockSubBlockValues,
|
||||
activeWorkflowId,
|
||||
])
|
||||
|
||||
const subBlockRows = subBlockRowsData.rows
|
||||
const subBlockState = subBlockRowsData.stateToUse
|
||||
const effectiveAdvanced = useMemo(() => {
|
||||
const rawValues = Object.entries(subBlockState).reduce<Record<string, unknown>>(
|
||||
(acc, [key, entry]) => {
|
||||
acc[key] = entry?.value
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return userPermissions.canEdit
|
||||
? displayAdvancedMode
|
||||
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
|
||||
}, [
|
||||
subBlockState,
|
||||
displayAdvancedMode,
|
||||
config.subBlocks,
|
||||
canonicalIndex,
|
||||
userPermissions.canEdit,
|
||||
])
|
||||
|
||||
/**
|
||||
* Determine if block has content below the header (subblocks or error row).
|
||||
@@ -883,7 +935,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
|
||||
const shouldShowScheduleBadge =
|
||||
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
||||
|
||||
return (
|
||||
@@ -1095,6 +1146,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
displayAdvancedOptions={effectiveAdvanced}
|
||||
canonicalIndex={canonicalIndex}
|
||||
canonicalModeOverrides={canonicalModeOverrides}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
@@ -26,9 +27,7 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
|
||||
const accessibleIds = new Set<string>(ancestorIds)
|
||||
accessibleIds.add(blockId)
|
||||
|
||||
const starterBlock = Object.values(blocks).find(
|
||||
(block) => block.type === 'starter' || block.type === 'start_trigger'
|
||||
)
|
||||
const starterBlock = Object.values(blocks).find((block) => isValidStartBlockType(block.type))
|
||||
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
||||
accessibleIds.add(starterBlock.id)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ interface UseBlockVisualProps {
|
||||
data: WorkflowBlockProps
|
||||
/** Whether the block is pending execution */
|
||||
isPending?: boolean
|
||||
/** Whether the block is selected (via shift-click or selection box) */
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +30,12 @@ interface UseBlockVisualProps {
|
||||
* @param props - The hook properties
|
||||
* @returns Visual state, click handler, and ring styling for the block
|
||||
*/
|
||||
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
|
||||
export function useBlockVisual({
|
||||
blockId,
|
||||
data,
|
||||
isPending = false,
|
||||
isSelected = false,
|
||||
}: UseBlockVisualProps) {
|
||||
const isPreview = data.isPreview ?? false
|
||||
const isPreviewSelected = data.isPreviewSelected ?? false
|
||||
|
||||
@@ -42,7 +49,6 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
isDeletedBlock,
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
// Check if the editor panel is open for this block
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const activeTab = usePanelStore((state) => state.activeTab)
|
||||
const isEditorOpen = !isPreview && currentBlockId === blockId && activeTab === 'editor'
|
||||
@@ -68,6 +74,7 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
diffStatus: isPreview ? undefined : diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection: isPreview && isPreviewSelected,
|
||||
isSelected: isPreview ? false : isSelected,
|
||||
}),
|
||||
[
|
||||
isExecuting,
|
||||
@@ -78,6 +85,7 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
runPathStatus,
|
||||
isPreview,
|
||||
isPreviewSelected,
|
||||
isSelected,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface BlockRingOptions {
|
||||
diffStatus: BlockDiffStatus
|
||||
runPathStatus: BlockRunPathStatus
|
||||
isPreviewSelection?: boolean
|
||||
/** Whether the block is selected via shift-click or selection box (shows blue ring) */
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,11 +34,13 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection,
|
||||
isSelected,
|
||||
} = options
|
||||
|
||||
const hasRing =
|
||||
isExecuting ||
|
||||
isEditorOpen ||
|
||||
isSelected ||
|
||||
isPending ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
@@ -46,25 +50,37 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
const ringClassName = cn(
|
||||
// Executing block: pulsing success ring with prominent thickness (highest priority)
|
||||
isExecuting && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Editor open or preview selection: static blue ring
|
||||
// Editor open, selected, or preview selection: static blue ring
|
||||
!isExecuting &&
|
||||
(isEditorOpen || isPreviewSelection) &&
|
||||
(isEditorOpen || isSelected || isPreviewSelection) &&
|
||||
'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||
// Non-active states use standard ring utilities
|
||||
!isExecuting && !isEditorOpen && !isPreviewSelection && hasRing && 'ring-[1.75px]',
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isSelected &&
|
||||
!isPreviewSelection &&
|
||||
hasRing &&
|
||||
'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isExecuting && !isEditorOpen && isPending && 'ring-[var(--warning)]',
|
||||
!isExecuting && !isEditorOpen && !isSelected && isPending && 'ring-[var(--warning)]',
|
||||
// Deleted state (highest priority after active/pending)
|
||||
!isExecuting && !isEditorOpen && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isSelected &&
|
||||
!isPending &&
|
||||
isDeletedBlock &&
|
||||
'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isSelected &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[var(--brand-tertiary-2)]',
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isSelected &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
@@ -72,6 +88,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isSelected &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
@@ -79,6 +96,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
'ring-[var(--border-success)]',
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isSelected &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
|
||||
@@ -700,7 +700,23 @@ const WorkflowContent = React.memo(() => {
|
||||
triggerMode,
|
||||
})
|
||||
|
||||
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
|
||||
const subBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
if (block.subBlocks && Object.keys(block.subBlocks).length > 0) {
|
||||
subBlockValues[id] = {}
|
||||
for (const [subBlockId, subBlock] of Object.entries(block.subBlocks)) {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
subBlockValues[id][subBlockId] = subBlock.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
[block],
|
||||
autoConnectEdge ? [autoConnectEdge] : [],
|
||||
{},
|
||||
{},
|
||||
subBlockValues
|
||||
)
|
||||
usePanelEditorStore.getState().setCurrentBlockId(id)
|
||||
},
|
||||
[collaborativeBatchAddBlocks, setSelectedEdges]
|
||||
|
||||
@@ -14,6 +14,13 @@ import { ReactFlowProvider } from 'reactflow'
|
||||
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
hasAdvancedValues,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components'
|
||||
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
@@ -24,56 +31,6 @@ import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Evaluate whether a subblock's condition is met based on current values.
|
||||
*/
|
||||
function evaluateCondition(
|
||||
condition: SubBlockConfig['condition'],
|
||||
subBlockValues: Record<string, { value: unknown } | unknown>
|
||||
): boolean {
|
||||
if (!condition) return true
|
||||
|
||||
const actualCondition = typeof condition === 'function' ? condition() : condition
|
||||
|
||||
const fieldValueObj = subBlockValues[actualCondition.field]
|
||||
const fieldValue =
|
||||
fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
|
||||
? (fieldValueObj as { value: unknown }).value
|
||||
: fieldValueObj
|
||||
|
||||
const conditionValues = Array.isArray(actualCondition.value)
|
||||
? actualCondition.value
|
||||
: [actualCondition.value]
|
||||
|
||||
let isMatch = conditionValues.some((v) => v === fieldValue)
|
||||
|
||||
if (actualCondition.not) {
|
||||
isMatch = !isMatch
|
||||
}
|
||||
|
||||
if (actualCondition.and && isMatch) {
|
||||
const andFieldValueObj = subBlockValues[actualCondition.and.field]
|
||||
const andFieldValue =
|
||||
andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
|
||||
? (andFieldValueObj as { value: unknown }).value
|
||||
: andFieldValueObj
|
||||
|
||||
const andConditionValues = Array.isArray(actualCondition.and.value)
|
||||
? actualCondition.and.value
|
||||
: [actualCondition.and.value]
|
||||
|
||||
let andMatch = andConditionValues.some((v) => v === andFieldValue)
|
||||
|
||||
if (actualCondition.and.not) {
|
||||
andMatch = !andMatch
|
||||
}
|
||||
|
||||
isMatch = isMatch && andMatch
|
||||
}
|
||||
|
||||
return isMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for display as JSON string
|
||||
*/
|
||||
@@ -1122,15 +1079,44 @@ function BlockDetailsSidebarContent({
|
||||
)
|
||||
}
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||
if (entry && typeof entry === 'object' && 'value' in entry) {
|
||||
acc[key] = (entry as { value: unknown }).value
|
||||
} else {
|
||||
acc[key] = entry
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}, [subBlockValues])
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig.subBlocks),
|
||||
[blockConfig.subBlocks]
|
||||
)
|
||||
const canonicalModeOverrides = block.data?.canonicalModes
|
||||
const effectiveAdvanced =
|
||||
(block.advancedMode ?? false) ||
|
||||
hasAdvancedValues(blockConfig.subBlocks, rawValues, canonicalIndex)
|
||||
|
||||
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden || subBlock.hideFromPreview) return false
|
||||
// Only filter out trigger-mode subblocks for non-trigger blocks
|
||||
// Trigger-only blocks (category 'triggers') should display their trigger subblocks
|
||||
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
|
||||
if (subBlock.condition) {
|
||||
return evaluateCondition(subBlock.condition, subBlockValues)
|
||||
if (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
subBlock,
|
||||
effectiveAdvanced,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return evaluateSubBlockCondition(subBlock.condition, rawValues)
|
||||
})
|
||||
|
||||
const statusVariant =
|
||||
|
||||
@@ -420,7 +420,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Help & Support</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
|
||||
@@ -1069,7 +1069,7 @@ export function AccessControl() {
|
||||
</Modal>
|
||||
|
||||
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -1185,7 +1185,7 @@ export function AccessControl() {
|
||||
</div>
|
||||
|
||||
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create Permission Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
@@ -1237,7 +1237,7 @@ export function AccessControl() {
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!deletingGroup} onOpenChange={() => setDeletingGroup(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Permission Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -392,7 +392,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function CreateApiKeyModal({
|
||||
<>
|
||||
{/* Create API Key Dialog */}
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -176,7 +176,7 @@ export function CreateApiKeyModal({
|
||||
data-form-type='other'
|
||||
/>
|
||||
{createError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
@@ -215,7 +215,7 @@ export function CreateApiKeyModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -276,7 +276,7 @@ export function BYOK() {
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
@@ -306,7 +306,7 @@ export function BYOK() {
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!deleteConfirmProvider} onOpenChange={() => setDeleteConfirmProvider(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API Key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -211,7 +211,7 @@ export function Copilot() {
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -234,7 +234,7 @@ export function Copilot() {
|
||||
autoFocus
|
||||
/>
|
||||
{createError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{createError}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
@@ -273,7 +273,7 @@ export function Copilot() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
@@ -310,7 +310,7 @@ export function Copilot() {
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -824,7 +824,7 @@ export function CredentialSets() {
|
||||
|
||||
{/* Create Polling Group Modal */}
|
||||
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create Polling Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
@@ -897,7 +897,7 @@ export function CredentialSets() {
|
||||
|
||||
{/* Leave Confirmation Modal */}
|
||||
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Leave Polling Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
@@ -925,7 +925,7 @@ export function CredentialSets() {
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Polling Group</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -206,7 +206,7 @@ export function CustomTools() {
|
||||
/>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Custom Tool</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -821,7 +821,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
</div>
|
||||
|
||||
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
|
||||
@@ -390,7 +390,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
|
||||
</div>
|
||||
|
||||
<Modal open={showDisconnectDialog} onOpenChange={setShowDisconnectDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Disconnect Service</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -3,13 +3,17 @@ import { Label } from '@/components/emcn'
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
export function FormField({ label, children }: FormFieldProps) {
|
||||
export function FormField({ label, children, optional }: FormFieldProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<Label className='w-[100px] shrink-0 font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
{label}
|
||||
{optional && (
|
||||
<span className='ml-1 font-normal text-[11px] text-[var(--text-muted)]'>(optional)</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className='relative flex-1'>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search, X } from 'lucide-react'
|
||||
import { ChevronDown, Plus, Search, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
@@ -77,10 +77,17 @@ interface EnvVarDropdownConfig {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface McpToolSchema {
|
||||
type: 'object'
|
||||
properties?: Record<string, unknown>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
interface McpTool {
|
||||
name: string
|
||||
description?: string
|
||||
serverId: string
|
||||
inputSchema?: McpToolSchema
|
||||
}
|
||||
|
||||
interface McpServer {
|
||||
@@ -381,6 +388,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const [refreshingServers, setRefreshingServers] = useState<
|
||||
Record<string, { status: 'refreshing' | 'refreshed'; workflowsUpdated?: number }>
|
||||
>({})
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
|
||||
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [envSearchTerm, setEnvSearchTerm] = useState('')
|
||||
@@ -669,6 +677,22 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
*/
|
||||
const handleBackToList = useCallback(() => {
|
||||
setSelectedServerId(null)
|
||||
setExpandedTools(new Set())
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles the expanded state of a tool's parameters.
|
||||
*/
|
||||
const toggleToolExpanded = useCallback((toolName: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(toolName)) {
|
||||
newSet.delete(toolName)
|
||||
} else {
|
||||
newSet.add(toolName)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -843,38 +867,113 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
{tools.map((tool) => {
|
||||
const issues = getStoredToolIssues(server.id, tool.name)
|
||||
const affectedWorkflows = issues.map((i) => i.workflowName)
|
||||
const isExpanded = expandedTools.has(tool.name)
|
||||
const hasParams =
|
||||
tool.inputSchema?.properties &&
|
||||
Object.keys(tool.inputSchema.properties).length > 0
|
||||
const requiredParams = tool.inputSchema?.required || []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
className='overflow-hidden rounded-[6px] border bg-[var(--surface-3)]'
|
||||
>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.name}
|
||||
</p>
|
||||
{issues.length > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<Badge
|
||||
variant={getIssueBadgeVariant(issues[0].issue)}
|
||||
size='sm'
|
||||
className='cursor-help'
|
||||
>
|
||||
{getIssueBadgeLabel(issues[0].issue)}
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
Update in: {affectedWorkflows.join(', ')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => hasParams && toggleToolExpanded(tool.name)}
|
||||
className={cn(
|
||||
'flex w-full items-start justify-between px-[10px] py-[8px] text-left',
|
||||
hasParams && 'cursor-pointer hover:bg-[var(--surface-4)]'
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
{tool.description}
|
||||
</p>
|
||||
disabled={!hasParams}
|
||||
>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.name}
|
||||
</p>
|
||||
{issues.length > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<Badge
|
||||
variant={getIssueBadgeVariant(issues[0].issue)}
|
||||
size='sm'
|
||||
className='cursor-help'
|
||||
>
|
||||
{getIssueBadgeLabel(issues[0].issue)}
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
Update in: {affectedWorkflows.join(', ')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{hasParams && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-[2px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && hasParams && (
|
||||
<div className='border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<p className='mb-[6px] font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
|
||||
Parameters
|
||||
</p>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
{Object.entries(tool.inputSchema!.properties!).map(
|
||||
([paramName, param]) => {
|
||||
const isRequired = requiredParams.includes(paramName)
|
||||
const paramType =
|
||||
typeof param === 'object' && param !== null
|
||||
? (param as { type?: string }).type || 'any'
|
||||
: 'any'
|
||||
const paramDesc =
|
||||
typeof param === 'object' && param !== null
|
||||
? (param as { description?: string }).description
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
key={paramName}
|
||||
className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-3)] px-[8px] py-[6px]'
|
||||
>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{paramName}
|
||||
</span>
|
||||
<Badge variant='outline' size='sm'>
|
||||
{paramType}
|
||||
</Badge>
|
||||
{isRequired && (
|
||||
<Badge variant='default' size='sm'>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{paramDesc && (
|
||||
<p className='mt-[3px] text-[11px] text-[var(--text-tertiary)] leading-relaxed'>
|
||||
{paramDesc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -1071,7 +1170,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
</div>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -245,10 +245,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
||||
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
||||
periodEndDate
|
||||
)}, then downgrade to free plan.`}{' '}
|
||||
{!isCancelAtPeriodEnd && (
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
)}
|
||||
)}, then downgrade to free plan. You can restore your subscription at any time.`}
|
||||
</p>
|
||||
|
||||
{!isCancelAtPeriodEnd && (
|
||||
@@ -266,7 +263,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='active'
|
||||
variant='default'
|
||||
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
|
||||
@@ -183,7 +183,7 @@ export function MemberInvitationCard({
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-1 text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='mt-1 text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
@@ -295,7 +295,7 @@ export function MemberInvitationCard({
|
||||
|
||||
{/* Invitation error - inline */}
|
||||
{invitationError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{invitationError instanceof Error && invitationError.message
|
||||
? invitationError.message
|
||||
: String(invitationError)}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function NoOrganizationView({
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{error && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
@@ -179,7 +179,7 @@ export function NoOrganizationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>}
|
||||
{error && <p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>}
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user