feat(agent): messages array, memory (#2023)

* feat(agent): messages array, memory options

* feat(messages-input): re-order messages

* backend for new memory setup, backwards compatibility in loadWorkflowsFromNormalizedTable from old agent block to new format

* added memories all conversation sliding token window, standardized modals

* lint

* fix build

* reorder popover for output selector for chat

* add internal auth, finish memories

* fix rebase

* fix failing test

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2025-11-18 15:58:10 -08:00
committed by GitHub
parent a8a693f1ff
commit 02d9fedf0c
53 changed files with 11580 additions and 1430 deletions

View File

@@ -563,6 +563,32 @@ logger.error('Operation failed', { error })
---
## Linting and Formatting
### Automated Linting
**Do not manually fix linting errors.** The project uses automated linting tools that should handle formatting and style issues.
### Rules
1. **No Manual Fixes**: Do not attempt to manually reorder CSS classes, fix formatting, or address linter warnings
2. **Use Automated Tools**: If linting errors need to be fixed, run `bun run lint` to let the automated tools handle it
3. **Focus on Logic**: Concentrate on functionality, TypeScript correctness, and architectural patterns
4. **Let Tools Handle Style**: Biome and other linters will automatically format code according to project standards
### When Linting Matters
- **Syntax Errors**: Fix actual syntax errors that prevent compilation
- **Type Errors**: Address TypeScript type errors that indicate logic issues
- **Ignore Style Warnings**: CSS class order, formatting preferences, etc. will be handled by tooling
```bash
# If linting is required
bun run lint
```
---
## Code Quality Checklist
Before considering a component/hook complete, verify:

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { memory, workflowBlocks } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -8,6 +8,43 @@ import { generateRequestId } from '@/lib/utils'
const logger = createLogger('MemoryByIdAPI')
/**
* Parse memory key into conversationId and blockId
* Key format: conversationId:blockId
*/
function parseMemoryKey(key: string): { conversationId: string; blockId: string } | null {
const parts = key.split(':')
if (parts.length !== 2) {
return null
}
return {
conversationId: parts[0],
blockId: parts[1],
}
}
/**
* Lookup block name from block ID
*/
async function getBlockName(blockId: string, workflowId: string): Promise<string | undefined> {
try {
const result = await db
.select({ name: workflowBlocks.name })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (result.length === 0) {
return undefined
}
return result[0].name
} catch (error) {
logger.error('Error looking up block name', { error, blockId, workflowId })
return undefined
}
}
const memoryQuerySchema = z.object({
workflowId: z.string().uuid('Invalid workflow ID format'),
})
@@ -41,7 +78,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
try {
logger.info(`[${requestId}] Processing memory get request for ID: ${id}`)
// Get workflowId from query parameter (required)
const url = new URL(request.url)
const workflowId = url.searchParams.get('workflowId')
@@ -65,7 +101,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { workflowId: validatedWorkflowId } = validation.data
// Query the database for the memory
const memories = await db
.select()
.from(memory)
@@ -86,13 +121,36 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
)
}
const mem = memories[0]
const parsed = parseMemoryKey(mem.key)
let enrichedMemory
if (!parsed) {
enrichedMemory = {
conversationId: mem.key,
blockId: 'unknown',
blockName: 'unknown',
data: mem.data,
}
} else {
const { conversationId, blockId } = parsed
const blockName = (await getBlockName(blockId, validatedWorkflowId)) || 'unknown'
enrichedMemory = {
conversationId,
blockId,
blockName,
data: mem.data,
}
}
logger.info(
`[${requestId}] Memory retrieved successfully: ${id} for workflow: ${validatedWorkflowId}`
)
return NextResponse.json(
{
success: true,
data: memories[0],
data: enrichedMemory,
},
{ status: 200 }
)
@@ -122,7 +180,6 @@ export async function DELETE(
try {
logger.info(`[${requestId}] Processing memory delete request for ID: ${id}`)
// Get workflowId from query parameter (required)
const url = new URL(request.url)
const workflowId = url.searchParams.get('workflowId')
@@ -146,7 +203,6 @@ export async function DELETE(
const { workflowId: validatedWorkflowId } = validation.data
// Verify memory exists before attempting to delete
const existingMemory = await db
.select({ id: memory.id })
.from(memory)
@@ -166,7 +222,6 @@ export async function DELETE(
)
}
// Hard delete the memory
await db
.delete(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
@@ -241,7 +296,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}
// Verify memory exists before attempting to update
const existingMemories = await db
.select()
.from(memory)
@@ -261,47 +315,68 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}
const existingMemory = existingMemories[0]
// Additional validation for agent memory type
if (existingMemory.type === 'agent') {
const agentValidation = agentMemoryDataSchema.safeParse(validatedData)
if (!agentValidation.success) {
const errorMessage = agentValidation.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
logger.warn(`[${requestId}] Agent memory validation error: ${errorMessage}`)
return NextResponse.json(
{
success: false,
error: {
message: `Invalid agent memory data: ${errorMessage}`,
},
const agentValidation = agentMemoryDataSchema.safeParse(validatedData)
if (!agentValidation.success) {
const errorMessage = agentValidation.error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ')
logger.warn(`[${requestId}] Agent memory validation error: ${errorMessage}`)
return NextResponse.json(
{
success: false,
error: {
message: `Invalid agent memory data: ${errorMessage}`,
},
{ status: 400 }
)
}
},
{ status: 400 }
)
}
// Update the memory with new data
const now = new Date()
await db
.delete(memory)
.update(memory)
.set({
data: validatedData,
updatedAt: now,
})
.where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
// Fetch the updated memory
const updatedMemories = await db
.select()
.from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId)))
.limit(1)
const mem = updatedMemories[0]
const parsed = parseMemoryKey(mem.key)
let enrichedMemory
if (!parsed) {
enrichedMemory = {
conversationId: mem.key,
blockId: 'unknown',
blockName: 'unknown',
data: mem.data,
}
} else {
const { conversationId, blockId } = parsed
const blockName = (await getBlockName(blockId, validatedWorkflowId)) || 'unknown'
enrichedMemory = {
conversationId,
blockId,
blockName,
data: mem.data,
}
}
logger.info(
`[${requestId}] Memory updated successfully: ${id} for workflow: ${validatedWorkflowId}`
)
return NextResponse.json(
{
success: true,
data: updatedMemories[0],
data: enrichedMemory,
},
{ status: 200 }
)

View File

@@ -1,15 +1,34 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { and, eq, isNull, like } from 'drizzle-orm'
import { memory, workflowBlocks } from '@sim/db/schema'
import { and, eq, inArray, isNull, like } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
const logger = createLogger('MemoryAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
/**
* Parse memory key into conversationId and blockId
* Key format: conversationId:blockId
* @param key The memory key to parse
* @returns Object with conversationId and blockId, or null if invalid
*/
function parseMemoryKey(key: string): { conversationId: string; blockId: string } | null {
const parts = key.split(':')
if (parts.length !== 2) {
return null
}
return {
conversationId: parts[0],
blockId: parts[1],
}
}
/**
* GET handler for searching and retrieving memories
* Supports query parameters:
@@ -22,16 +41,28 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized memory access attempt`)
return NextResponse.json(
{
success: false,
error: {
message: authResult.error || 'Authentication required',
},
},
{ status: 401 }
)
}
logger.info(`[${requestId}] Processing memory search request`)
// Extract workflowId from query parameters
const url = new URL(request.url)
const workflowId = url.searchParams.get('workflowId')
const searchQuery = url.searchParams.get('query')
const type = url.searchParams.get('type')
const blockNameFilter = url.searchParams.get('blockName')
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
// Require workflowId for security
if (!workflowId) {
logger.warn(`[${requestId}] Missing required parameter: workflowId`)
return NextResponse.json(
@@ -45,38 +76,148 @@ export async function GET(request: NextRequest) {
)
}
// Build query conditions
const conditions = []
// Only include non-deleted memories
conditions.push(isNull(memory.deletedAt))
// Filter by workflow ID (required)
conditions.push(eq(memory.workflowId, workflowId))
// Add type filter if provided
if (type) {
conditions.push(eq(memory.type, type))
const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId)
if (!accessContext) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found for user ${authResult.userId}`)
return NextResponse.json(
{
success: false,
error: {
message: 'Workflow not found',
},
},
{ status: 404 }
)
}
const { workspacePermission, isOwner } = accessContext
if (!isOwner && !workspacePermission) {
logger.warn(
`[${requestId}] User ${authResult.userId} denied access to workflow ${workflowId}`
)
return NextResponse.json(
{
success: false,
error: {
message: 'Access denied to this workflow',
},
},
{ status: 403 }
)
}
logger.info(
`[${requestId}] User ${authResult.userId} (${authResult.authType}) accessing memories for workflow ${workflowId}`
)
const conditions = []
conditions.push(isNull(memory.deletedAt))
conditions.push(eq(memory.workflowId, workflowId))
let blockIdsToFilter: string[] | null = null
if (blockNameFilter) {
const blocks = await db
.select({ id: workflowBlocks.id })
.from(workflowBlocks)
.where(
and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.name, blockNameFilter))
)
if (blocks.length === 0) {
logger.info(
`[${requestId}] No blocks found with name "${blockNameFilter}" for workflow: ${workflowId}`
)
return NextResponse.json(
{
success: true,
data: { memories: [] },
},
{ status: 200 }
)
}
blockIdsToFilter = blocks.map((b) => b.id)
}
// Add search query if provided (leverages index on key field)
if (searchQuery) {
conditions.push(like(memory.key, `%${searchQuery}%`))
}
// Execute the query
const memories = await db
const rawMemories = await db
.select()
.from(memory)
.where(and(...conditions))
.orderBy(memory.createdAt)
.limit(limit)
logger.info(`[${requestId}] Found ${memories.length} memories for workflow: ${workflowId}`)
const filteredMemories = blockIdsToFilter
? rawMemories.filter((mem) => {
const parsed = parseMemoryKey(mem.key)
return parsed && blockIdsToFilter.includes(parsed.blockId)
})
: rawMemories
const blockIds = new Set<string>()
const parsedKeys = new Map<string, { conversationId: string; blockId: string }>()
for (const mem of filteredMemories) {
const parsed = parseMemoryKey(mem.key)
if (parsed) {
blockIds.add(parsed.blockId)
parsedKeys.set(mem.key, parsed)
}
}
const blockNameMap = new Map<string, string>()
if (blockIds.size > 0) {
const blocks = await db
.select({ id: workflowBlocks.id, name: workflowBlocks.name })
.from(workflowBlocks)
.where(
and(
eq(workflowBlocks.workflowId, workflowId),
inArray(workflowBlocks.id, Array.from(blockIds))
)
)
for (const block of blocks) {
blockNameMap.set(block.id, block.name)
}
}
const enrichedMemories = filteredMemories.map((mem) => {
const parsed = parsedKeys.get(mem.key)
if (!parsed) {
return {
conversationId: mem.key,
blockId: 'unknown',
blockName: 'unknown',
data: mem.data,
}
}
const { conversationId, blockId } = parsed
const blockName = blockNameMap.get(blockId) || 'unknown'
return {
conversationId,
blockId,
blockName,
data: mem.data,
}
})
logger.info(
`[${requestId}] Found ${enrichedMemories.length} memories for workflow: ${workflowId}`
)
return NextResponse.json(
{
success: true,
data: { memories },
data: { memories: enrichedMemories },
},
{ status: 200 }
)
@@ -105,13 +246,25 @@ export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized memory creation attempt`)
return NextResponse.json(
{
success: false,
error: {
message: authResult.error || 'Authentication required',
},
},
{ status: 401 }
)
}
logger.info(`[${requestId}] Processing memory creation request`)
// Parse request body
const body = await request.json()
const { key, type, data, workflowId } = body
const { key, data, workflowId } = body
// Validate required fields
if (!key) {
logger.warn(`[${requestId}] Missing required field: key`)
return NextResponse.json(
@@ -125,19 +278,6 @@ export async function POST(request: NextRequest) {
)
}
if (!type || type !== 'agent') {
logger.warn(`[${requestId}] Invalid memory type: ${type}`)
return NextResponse.json(
{
success: false,
error: {
message: 'Memory type must be "agent"',
},
},
{ status: 400 }
)
}
if (!data) {
logger.warn(`[${requestId}] Missing required field: data`)
return NextResponse.json(
@@ -164,28 +304,67 @@ export async function POST(request: NextRequest) {
)
}
// Additional validation for agent type
if (type === 'agent') {
if (!data.role || !data.content) {
logger.warn(`[${requestId}] Missing agent memory fields`)
const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId)
if (!accessContext) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found for user ${authResult.userId}`)
return NextResponse.json(
{
success: false,
error: {
message: 'Workflow not found',
},
},
{ status: 404 }
)
}
const { workspacePermission, isOwner } = accessContext
const hasWritePermission =
isOwner || workspacePermission === 'write' || workspacePermission === 'admin'
if (!hasWritePermission) {
logger.warn(
`[${requestId}] User ${authResult.userId} denied write access to workflow ${workflowId}`
)
return NextResponse.json(
{
success: false,
error: {
message: 'Write access denied to this workflow',
},
},
{ status: 403 }
)
}
logger.info(
`[${requestId}] User ${authResult.userId} (${authResult.authType}) creating memory for workflow ${workflowId}`
)
const dataToValidate = Array.isArray(data) ? data : [data]
for (const msg of dataToValidate) {
if (!msg || typeof msg !== 'object' || !msg.role || !msg.content) {
logger.warn(`[${requestId}] Missing required message fields`)
return NextResponse.json(
{
success: false,
error: {
message: 'Agent memory requires role and content',
message: 'Memory requires messages with role and content',
},
},
{ status: 400 }
)
}
if (!['user', 'assistant', 'system'].includes(data.role)) {
logger.warn(`[${requestId}] Invalid agent role: ${data.role}`)
if (!['user', 'assistant', 'system'].includes(msg.role)) {
logger.warn(`[${requestId}] Invalid message role: ${msg.role}`)
return NextResponse.json(
{
success: false,
error: {
message: 'Agent role must be user, assistant, or system',
message: 'Message role must be user, assistant, or system',
},
},
{ status: 400 }
@@ -193,77 +372,34 @@ export async function POST(request: NextRequest) {
}
}
// Check if memory with the same key already exists for this workflow
const existingMemory = await db
.select()
.from(memory)
.where(and(eq(memory.key, key), eq(memory.workflowId, workflowId), isNull(memory.deletedAt)))
.limit(1)
const initialData = Array.isArray(data) ? data : [data]
const now = new Date()
const id = `mem_${crypto.randomUUID().replace(/-/g, '')}`
let statusCode = 201 // Default status code for new memory
const { sql } = await import('drizzle-orm')
if (existingMemory.length > 0) {
logger.info(`[${requestId}] Memory with key ${key} exists, checking if we can append`)
// Check if types match
if (existingMemory[0].type !== type) {
logger.warn(
`[${requestId}] Memory type mismatch: existing=${existingMemory[0].type}, new=${type}`
)
return NextResponse.json(
{
success: false,
error: {
message: `Cannot append memory of type '${type}' to existing memory of type '${existingMemory[0].type}'`,
},
},
{ status: 400 }
)
}
// Handle appending for agent type
let updatedData
// For agent type
const newMessage = data
const existingData = existingMemory[0].data
// If existing data is an array, append to it
if (Array.isArray(existingData)) {
updatedData = [...existingData, newMessage]
}
// If existing data is a single message object, convert to array
else {
updatedData = [existingData, newMessage]
}
// Update the existing memory with appended data
await db
.update(memory)
.set({
data: updatedData,
updatedAt: new Date(),
})
.where(and(eq(memory.key, key), eq(memory.workflowId, workflowId)))
statusCode = 200 // Status code for updated memory
} else {
// Insert the new memory
const newMemory = {
id: `mem_${crypto.randomUUID().replace(/-/g, '')}`,
await db
.insert(memory)
.values({
id,
workflowId,
key,
type,
data: Array.isArray(data) ? data : [data],
createdAt: new Date(),
updatedAt: new Date(),
}
data: initialData,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [memory.workflowId, memory.key],
set: {
data: sql`${memory.data} || ${JSON.stringify(initialData)}::jsonb`,
updatedAt: now,
},
})
await db.insert(memory).values(newMemory)
logger.info(`[${requestId}] Memory created successfully: ${key} for workflow: ${workflowId}`)
}
logger.info(
`[${requestId}] Memory operation successful (atomic): ${key} for workflow: ${workflowId}`
)
// Fetch all memories with the same key for this workflow to return the complete list
const allMemories = await db
.select()
.from(memory)
@@ -271,7 +407,6 @@ export async function POST(request: NextRequest) {
.orderBy(memory.createdAt)
if (allMemories.length === 0) {
// This shouldn't happen but handle it just in case
logger.warn(`[${requestId}] No memories found after creating/updating memory: ${key}`)
return NextResponse.json(
{
@@ -284,19 +419,44 @@ export async function POST(request: NextRequest) {
)
}
// Get the memory object to return
const memoryRecord = allMemories[0]
const parsed = parseMemoryKey(memoryRecord.key)
let enrichedMemory
if (!parsed) {
enrichedMemory = {
conversationId: memoryRecord.key,
blockId: 'unknown',
blockName: 'unknown',
data: memoryRecord.data,
}
} else {
const { conversationId, blockId } = parsed
const blockName = await (async () => {
const blocks = await db
.select({ name: workflowBlocks.name })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
return blocks.length > 0 ? blocks[0].name : 'unknown'
})()
enrichedMemory = {
conversationId,
blockId,
blockName,
data: memoryRecord.data,
}
}
logger.info(`[${requestId}] Memory operation successful: ${key} for workflow: ${workflowId}`)
return NextResponse.json(
{
success: true,
data: memoryRecord,
data: enrichedMemory,
},
{ status: statusCode }
{ status: 200 }
)
} catch (error: any) {
// Handle unique constraint violation
if (error.code === '23505') {
logger.warn(`[${requestId}] Duplicate key violation`)
return NextResponse.json(
@@ -321,3 +481,215 @@ export async function POST(request: NextRequest) {
)
}
}
/**
* DELETE handler for pattern-based memory deletion
* Supports query parameters:
* - workflowId: Required
* - conversationId: Optional - delete all memories for this conversation
* - blockId: Optional - delete all memories for this block
* - blockName: Optional - delete all memories for blocks with this name
*/
export async function DELETE(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized memory deletion attempt`)
return NextResponse.json(
{
success: false,
error: {
message: authResult.error || 'Authentication required',
},
},
{ status: 401 }
)
}
logger.info(`[${requestId}] Processing memory deletion request`)
const url = new URL(request.url)
const workflowId = url.searchParams.get('workflowId')
const conversationId = url.searchParams.get('conversationId')
const blockId = url.searchParams.get('blockId')
const blockName = url.searchParams.get('blockName')
if (!workflowId) {
logger.warn(`[${requestId}] Missing required parameter: workflowId`)
return NextResponse.json(
{
success: false,
error: {
message: 'workflowId parameter is required',
},
},
{ status: 400 }
)
}
if (!conversationId && !blockId && !blockName) {
logger.warn(`[${requestId}] No filter parameters provided`)
return NextResponse.json(
{
success: false,
error: {
message: 'At least one of conversationId, blockId, or blockName must be provided',
},
},
{ status: 400 }
)
}
const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId)
if (!accessContext) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found for user ${authResult.userId}`)
return NextResponse.json(
{
success: false,
error: {
message: 'Workflow not found',
},
},
{ status: 404 }
)
}
const { workspacePermission, isOwner } = accessContext
const hasWritePermission =
isOwner || workspacePermission === 'write' || workspacePermission === 'admin'
if (!hasWritePermission) {
logger.warn(
`[${requestId}] User ${authResult.userId} denied delete access to workflow ${workflowId}`
)
return NextResponse.json(
{
success: false,
error: {
message: 'Write access denied to this workflow',
},
},
{ status: 403 }
)
}
logger.info(
`[${requestId}] User ${authResult.userId} (${authResult.authType}) deleting memories for workflow ${workflowId}`
)
let deletedCount = 0
if (conversationId && blockId) {
const key = `${conversationId}:${blockId}`
const result = await db
.delete(memory)
.where(and(eq(memory.key, key), eq(memory.workflowId, workflowId)))
.returning({ id: memory.id })
deletedCount = result.length
} else if (conversationId && blockName) {
const blocks = await db
.select({ id: workflowBlocks.id })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.name, blockName)))
if (blocks.length === 0) {
return NextResponse.json(
{
success: true,
data: {
message: `No blocks found with name "${blockName}"`,
deletedCount: 0,
},
},
{ status: 200 }
)
}
for (const block of blocks) {
const key = `${conversationId}:${block.id}`
const result = await db
.delete(memory)
.where(and(eq(memory.key, key), eq(memory.workflowId, workflowId)))
.returning({ id: memory.id })
deletedCount += result.length
}
} else if (conversationId) {
const pattern = `${conversationId}:%`
const result = await db
.delete(memory)
.where(and(like(memory.key, pattern), eq(memory.workflowId, workflowId)))
.returning({ id: memory.id })
deletedCount = result.length
} else if (blockId) {
const pattern = `%:${blockId}`
const result = await db
.delete(memory)
.where(and(like(memory.key, pattern), eq(memory.workflowId, workflowId)))
.returning({ id: memory.id })
deletedCount = result.length
} else if (blockName) {
const blocks = await db
.select({ id: workflowBlocks.id })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.name, blockName)))
if (blocks.length === 0) {
return NextResponse.json(
{
success: true,
data: {
message: `No blocks found with name "${blockName}"`,
deletedCount: 0,
},
},
{ status: 200 }
)
}
for (const block of blocks) {
const pattern = `%:${block.id}`
const result = await db
.delete(memory)
.where(and(like(memory.key, pattern), eq(memory.workflowId, workflowId)))
.returning({ id: memory.id })
deletedCount += result.length
}
}
logger.info(
`[${requestId}] Successfully deleted ${deletedCount} memories for workflow: ${workflowId}`
)
return NextResponse.json(
{
success: true,
data: {
message:
deletedCount > 0
? `Successfully deleted ${deletedCount} memories`
: 'No memories found matching the criteria',
deletedCount,
},
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Error deleting memories`, { error })
return NextResponse.json(
{
success: false,
error: {
message: error.message || 'Failed to delete memories',
},
},
{ status: 500 }
)
}
}

View File

@@ -2,17 +2,16 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChunkData } from '@/stores/knowledge/store'
@@ -76,20 +75,30 @@ export function DeleteChunkModal({
if (!chunk) return null
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chunk</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this chunk? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete Chunk</ModalTitle>
<ModalDescription>
Are you sure you want to delete this chunk?{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
variant='outline'
disabled={isDeleting}
onClick={onClose}
className='h-[32px] px-[12px]'
>
Cancel
</Button>
<Button
onClick={handleDeleteChunk}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isDeleting ? (
<>
@@ -102,9 +111,9 @@ export function DeleteChunkModal({
Delete
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -625,16 +625,6 @@ export function Chat() {
style={{ width: '110px', minWidth: '110px' }}
>
<PopoverScrollArea>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
}}
disabled={messages.length === 0}
>
<ArrowDownToLine className='h-[14px] w-[14px]' />
<span>Download</span>
</PopoverItem>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
@@ -645,6 +635,16 @@ export function Chat() {
<Trash className='h-[14px] w-[14px]' />
<span>Clear</span>
</PopoverItem>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
}}
disabled={messages.length === 0}
>
<ArrowDownToLine className='h-[14px] w-[14px]' />
<span>Download</span>
</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
</Popover>

View File

@@ -23,6 +23,11 @@ interface OutputSelectProps {
disabled?: boolean
placeholder?: string
valueMode?: 'id' | 'label'
/**
* When true, renders the underlying popover content inline instead of in a portal.
* Useful when used inside dialogs or other portalled components that manage scroll locking.
*/
disablePopoverPortal?: boolean
align?: 'start' | 'end' | 'center'
maxHeight?: number
}
@@ -34,6 +39,7 @@ export function OutputSelect({
disabled = false,
placeholder = 'Select outputs',
valueMode = 'id',
disablePopoverPortal = false,
align = 'start',
maxHeight = 300,
}: OutputSelectProps) {
@@ -374,6 +380,7 @@ export function OutputSelect({
maxHeight={maxHeight}
maxWidth={160}
minWidth={160}
disablePortal={disablePopoverPortal}
onKeyDown={handleKeyDown}
tabIndex={0}
style={{ outline: 'none' }}

View File

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

View File

@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { Input, Label, Textarea } from '@/components/emcn'
import {
Alert,
AlertDescription,
@@ -16,10 +17,7 @@ import {
Card,
CardContent,
ImageUpload,
Input,
Label,
Skeleton,
Textarea,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Plus, RefreshCw } from 'lucide-react'
import { Input, Label } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button, Card, CardContent, Input, Label } from '@/components/ui'
import { Button, Card, CardContent } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { cn, generatePassword } from '@/lib/utils'
import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { Input, Label } from '@/components/ui'
import { Input, Label } from '@/components/emcn'
import { getEmailDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import { useIdentifierValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation'

View File

@@ -1,4 +1,4 @@
import { Label } from '@/components/ui'
import { Label } from '@/components/emcn'
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
interface ExistingChat {

View File

@@ -1,7 +1,7 @@
'use client'
import { Label } from '@/components/emcn'
import { CopyButton } from '@/components/ui/copy-button'
import { Label } from '@/components/ui/label'
interface ApiEndpointProps {
endpoint: string

View File

@@ -1,48 +0,0 @@
'use client'
import { Label } from '@/components/ui/label'
interface ApiKeyProps {
apiKey: string
showLabel?: boolean
}
export function ApiKey({ apiKey, showLabel = true }: ApiKeyProps) {
// Extract key name and type from the API response format "Name (type)"
const getKeyInfo = (keyInfo: string) => {
if (!keyInfo || keyInfo.includes('No API key found')) {
return { name: keyInfo, type: null }
}
const match = keyInfo.match(/^(.*?)\s+\(([^)]+)\)$/)
if (match) {
return { name: match[1].trim(), type: match[2] }
}
return { name: keyInfo, type: null }
}
const { name, type } = getKeyInfo(apiKey)
return (
<div className='space-y-1.5'>
{showLabel && (
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>API Key</Label>
</div>
)}
<div className='rounded-md border bg-background'>
<div className='flex items-center justify-between p-3'>
<pre className='flex-1 overflow-x-auto whitespace-pre-wrap font-mono text-xs'>{name}</pre>
{type && (
<div className='ml-2 flex-shrink-0'>
<span className='inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs capitalize'>
{type}
</span>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -2,7 +2,8 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button, Label } from '@/components/emcn'
import { Button as UIButton } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import {
DropdownMenu,
@@ -10,7 +11,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { getEnv, isTruthy } from '@/lib/env'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
@@ -147,46 +147,31 @@ export function ExampleCommand({
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='sm'
variant={mode === 'sync' ? 'primary' : 'outline'}
onClick={() => setMode('sync')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'sync'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
Sync
</Button>
<Button
variant='outline'
size='sm'
variant={mode === 'stream' ? 'primary' : 'outline'}
onClick={() => setMode('stream')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'stream'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
Stream
</Button>
{isAsyncEnabled && (
<>
<Button
variant='outline'
size='sm'
variant={mode === 'async' ? 'primary' : 'outline'}
onClick={() => setMode('async')}
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
mode === 'async'
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
: ''
}`}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
Async
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
<UIButton
variant='outline'
size='sm'
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
@@ -194,7 +179,7 @@ export function ExampleCommand({
>
<span className='truncate'>{getExampleTitle()}</span>
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
</Button>
</UIButton>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='z-[10000050]'>
<DropdownMenuItem

View File

@@ -1,4 +1,3 @@
export { ApiEndpoint } from './api-endpoint/api-endpoint'
export { ApiKey } from './api-key/api-key'
export { DeployStatus } from './deploy-status/deploy-status'
export { ExampleCommand } from './example-command/example-command'

View File

@@ -3,18 +3,15 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
Skeleton,
} from '@/components/ui'
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import {
ApiEndpoint,
DeployStatus,
@@ -64,6 +61,7 @@ export function DeploymentInfo({
onLoadDeploymentComplete,
}: DeploymentInfoProps) {
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
const [showUndeployModal, setShowUndeployModal] = useState(false)
const handleViewDeployed = async () => {
if (!workflowId) {
@@ -130,41 +128,28 @@ export function DeploymentInfo({
<DeployStatus needsRedeployment={deploymentInfo.needsRedeployment} />
<div className='flex gap-2'>
<Button variant='outline' size='sm' onClick={handleViewDeployed}>
<Button variant='outline' onClick={handleViewDeployed} className='h-8 text-xs'>
View Deployment
</Button>
{deploymentInfo.needsRedeployment && (
<Button variant='outline' size='sm' onClick={onRedeploy} disabled={isSubmitting}>
<Button
variant='outline'
onClick={onRedeploy}
disabled={isSubmitting}
className='h-8 text-xs'
>
{isSubmitting ? <Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' /> : null}
{isSubmitting ? 'Redeploying...' : 'Redeploy'}
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant='destructive' size='sm' disabled={isUndeploying}>
{isUndeploying ? <Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' /> : null}
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Undeploy API</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to undeploy this workflow? This will remove the API
endpoint and make it unavailable to external users.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onUndeploy}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Undeploy
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
disabled={isUndeploying}
className='h-8 bg-red-500 text-white text-xs hover:bg-red-600'
onClick={() => setShowUndeployModal(true)}
>
{isUndeploying ? <Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' /> : null}
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</div>
</div>
</div>
@@ -179,6 +164,42 @@ export function DeploymentInfo({
onLoadDeploymentComplete={onLoadDeploymentComplete}
/>
)}
{/* Undeploy Confirmation Modal */}
<Modal open={showUndeployModal} onOpenChange={setShowUndeployModal}>
<ModalContent>
<ModalHeader>
<ModalTitle>Undeploy API</ModalTitle>
<ModalDescription>
Are you sure you want to undeploy this workflow? This will remove the API endpoint and
make it unavailable to external users.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
className='h-[32px] px-[12px]'
variant='outline'
onClick={() => setShowUndeployModal(false)}
disabled={isUndeploying}
>
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
onClick={() => {
onUndeploy()
setShowUndeployModal(false)
}}
disabled={isUndeploying}
>
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -2,8 +2,8 @@
import { useEffect, useRef, useState } from 'react'
import { Loader2, MoreVertical, X } from 'lucide-react'
import { Badge, Button } from '@/components/emcn'
import {
Button,
Dialog,
DialogContent,
DialogHeader,
@@ -12,10 +12,10 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Button as UIButton,
} from '@/components/ui'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers'
import {
@@ -658,19 +658,17 @@ export function DeployModal({
<div className='flex items-center gap-2'>
<DialogTitle className='font-medium text-lg'>Deploy Workflow</DialogTitle>
{needsRedeployment && versions.length > 0 && versionToActivate === null && (
<span className='inline-flex items-center rounded-md bg-purple-500/10 px-2 py-1 font-medium text-purple-600 text-xs dark:text-purple-400'>
<Badge
variant='outline'
className='border-purple-500/20 bg-purple-500/10 text-purple-600 dark:text-purple-400'
>
{versions.find((v) => v.isActive)?.name ||
`v${versions.find((v) => v.isActive)?.version}`}{' '}
active
</span>
</Badge>
)}
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={handleCloseModal}
>
<Button variant='ghost' className='h-8 w-8 p-0' onClick={handleCloseModal}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
@@ -680,46 +678,30 @@ export function DeployModal({
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex h-14 flex-none items-center border-b px-6'>
<div className='flex gap-2'>
<button
<Button
variant={activeTab === 'api' ? 'active' : 'default'}
onClick={() => setActiveTab('api')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'api'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
API
</button>
<button
</Button>
<Button
variant={activeTab === 'chat' ? 'active' : 'default'}
onClick={() => setActiveTab('chat')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'chat'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Chat
</button>
<button
</Button>
<Button
variant={activeTab === 'versions' ? 'active' : 'default'}
onClick={() => setActiveTab('versions')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'versions'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Versions
</button>
<button
</Button>
<Button
variant={activeTab === 'template' ? 'active' : 'default'}
onClick={() => setActiveTab('template')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'template'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Template
</button>
</Button>
</div>
</div>
@@ -743,17 +725,7 @@ export function DeployModal({
} to production.`}
</div>
<div className='flex gap-2'>
<Button
onClick={onDeploy}
disabled={isSubmitting}
className={cn(
'gap-2 font-medium',
'bg-[var(--brand-primary-hover-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hover-hex)] disabled:hover:shadow-none'
)}
>
<Button variant='primary' onClick={onDeploy} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
@@ -893,14 +865,14 @@ export function DeployModal({
}
>
<DropdownMenuTrigger asChild>
<Button
<UIButton
variant='ghost'
size='icon'
className='h-8 w-8'
disabled={activatingVersion === v.version}
>
<MoreVertical className='h-4 w-4' />
</Button>
</UIButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
@@ -933,22 +905,22 @@ export function DeployModal({
{versions.length}
</span>
<div className='flex gap-2'>
<Button
<UIButton
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</Button>
<Button
</UIButton>
<UIButton
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage * itemsPerPage >= versions.length}
>
Next
</Button>
</UIButton>
</div>
</div>
)}
@@ -1001,28 +973,16 @@ export function DeployModal({
}
}}
disabled={chatSubmitting}
className={cn(
'gap-2 font-medium',
'bg-red-500 hover:bg-red-600',
'shadow-[0_0_0_0_rgb(239,68,68)] hover:shadow-[0_0_0_4px_rgba(239,68,68,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-red-500 disabled:hover:shadow-none'
)}
className='bg-red-500 text-white hover:bg-red-600'
>
Delete
</Button>
)}
<Button
type='button'
variant='primary'
onClick={handleChatFormSubmit}
disabled={chatSubmitting || !isChatFormValid}
className={cn(
'gap-2 font-medium',
'bg-[var(--brand-primary-hover-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hover-hex)] disabled:hover:shadow-none'
)}
>
{chatSubmitting ? (
<>

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/emcn'
import {
AlertDialog,
AlertDialogAction,
@@ -12,7 +13,6 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console/logger'
import { DeployedWorkflowCard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-card'

View File

@@ -14,16 +14,17 @@ import {
Search,
Trash2,
} from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button as EmcnButton,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Tooltip,
} from '@/components/emcn'
import {
Button,
Dialog,
DialogContent,
@@ -1162,29 +1163,36 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
</DialogContent>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={handleDeleteDialogClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete webhook?</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showDeleteDialog} onOpenChange={handleDeleteDialogClose}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete webhook?</ModalTitle>
<ModalDescription>
This will permanently remove the webhook configuration and stop all notifications.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<EmcnButton
variant='outline'
className='h-[32px] px-[12px]'
disabled={isDeleting}
onClick={handleDeleteDialogClose}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
</EmcnButton>
<EmcnButton
onClick={confirmDeleteWebhook}
disabled={isDeleting}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</EmcnButton>
</ModalFooter>
</ModalContent>
</Modal>
</Dialog>
)
}

View File

@@ -19,6 +19,7 @@ export { LongInput } from './long-input/long-input'
export { McpDynamicArgs } from './mcp-dynamic-args/mcp-dynamic-args'
export { McpServerSelector } from './mcp-server-modal/mcp-server-selector'
export { McpToolSelector } from './mcp-server-modal/mcp-tool-selector'
export { MessagesInput } from './messages-input/messages-input'
export { ProjectSelectorInput } from './project-selector/project-selector-input'
export { ResponseFormat } from './response/response-format'
export { ScheduleSave } from './schedule-save/schedule-save'

View File

@@ -0,0 +1,532 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/utils'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
const MIN_TEXTAREA_HEIGHT_PX = 80
/**
* Interface for individual message in the messages array
*/
interface Message {
role: 'system' | 'user' | 'assistant'
content: string
}
/**
* Props for the MessagesInput component
*/
interface MessagesInputProps {
/** Unique identifier for the block */
blockId: string
/** Unique identifier for the sub-block */
subBlockId: string
/** Configuration object for the sub-block */
config: SubBlockConfig
/** Whether component is in preview mode */
isPreview?: boolean
/** Value to display in preview mode */
previewValue?: Message[] | null
/** Whether the input is disabled */
disabled?: boolean
}
/**
* MessagesInput component for managing LLM message history
*
* @remarks
* - Manages an array of messages with role and content
* - Each message can be edited, removed, or reordered
* - Stores data in LLM-compatible format: [{ role, content }]
*/
export function MessagesInput({
blockId,
subBlockId,
config,
isPreview = false,
previewValue,
disabled = false,
}: MessagesInputProps) {
const [messages, setMessages] = useSubBlockValue<Message[]>(blockId, subBlockId, false)
const [localMessages, setLocalMessages] = useState<Message[]>([{ role: 'user', content: '' }])
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const [openPopoverIndex, setOpenPopoverIndex] = useState<number | null>(null)
const subBlockInput = useSubBlockInput({
blockId,
subBlockId,
config,
isPreview,
disabled,
})
/**
* Initialize local state from stored or preview value
*/
useEffect(() => {
if (isPreview && previewValue && Array.isArray(previewValue)) {
setLocalMessages(previewValue)
} else if (messages && Array.isArray(messages) && messages.length > 0) {
setLocalMessages(messages)
}
}, [isPreview, previewValue, messages])
/**
* Gets the current messages array
*/
const currentMessages = useMemo<Message[]>(() => {
if (isPreview && previewValue && Array.isArray(previewValue)) {
return previewValue
}
return localMessages
}, [isPreview, previewValue, localMessages])
const overlayRefs = useRef<Record<string, HTMLDivElement | null>>({})
const textareaRefs = useRef<Record<string, HTMLTextAreaElement | null>>({})
const userResizedRef = useRef<Record<string, boolean>>({})
const isResizingRef = useRef(false)
const resizeStateRef = useRef<{
fieldId: string
startY: number
startHeight: number
} | null>(null)
/**
* Updates a specific message's content
*/
const updateMessageContent = useCallback(
(index: number, content: string) => {
if (isPreview || disabled) return
const updatedMessages = [...localMessages]
updatedMessages[index] = {
...updatedMessages[index],
content,
}
setLocalMessages(updatedMessages)
setMessages(updatedMessages)
},
[localMessages, setMessages, isPreview, disabled]
)
/**
* Updates a specific message's role
*/
const updateMessageRole = useCallback(
(index: number, role: 'system' | 'user' | 'assistant') => {
if (isPreview || disabled) return
const updatedMessages = [...localMessages]
updatedMessages[index] = {
...updatedMessages[index],
role,
}
setLocalMessages(updatedMessages)
setMessages(updatedMessages)
},
[localMessages, setMessages, isPreview, disabled]
)
/**
* Adds a message after the specified index
*/
const addMessageAfter = useCallback(
(index: number) => {
if (isPreview || disabled) return
const newMessages = [...localMessages]
newMessages.splice(index + 1, 0, { role: 'user' as const, content: '' })
setLocalMessages(newMessages)
setMessages(newMessages)
},
[localMessages, setMessages, isPreview, disabled]
)
/**
* Deletes a message at the specified index
*/
const deleteMessage = useCallback(
(index: number) => {
if (isPreview || disabled) return
const newMessages = [...localMessages]
newMessages.splice(index, 1)
setLocalMessages(newMessages)
setMessages(newMessages)
},
[localMessages, setMessages, isPreview, disabled]
)
/**
* Moves a message up in the list
*/
const moveMessageUp = useCallback(
(index: number) => {
if (isPreview || disabled || index === 0) return
const newMessages = [...localMessages]
const temp = newMessages[index]
newMessages[index] = newMessages[index - 1]
newMessages[index - 1] = temp
setLocalMessages(newMessages)
setMessages(newMessages)
},
[localMessages, setMessages, isPreview, disabled]
)
/**
* Moves a message down in the list
*/
const moveMessageDown = useCallback(
(index: number) => {
if (isPreview || disabled || index === localMessages.length - 1) return
const newMessages = [...localMessages]
const temp = newMessages[index]
newMessages[index] = newMessages[index + 1]
newMessages[index + 1] = temp
setLocalMessages(newMessages)
setMessages(newMessages)
},
[localMessages, setMessages, isPreview, disabled]
)
/**
* Capitalizes the first letter of the role
*/
const formatRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
/**
* Handles header click to focus the textarea
*/
const handleHeaderClick = useCallback((index: number, e: React.MouseEvent) => {
// Don't focus if clicking on interactive elements
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-radix-popper-content-wrapper]')) {
return
}
const fieldId = `message-${index}`
textareaRefs.current[fieldId]?.focus()
}, [])
const autoResizeTextarea = useCallback((fieldId: string) => {
const textarea = textareaRefs.current[fieldId]
if (!textarea) return
const overlay = overlayRefs.current[fieldId]
// If user has manually resized, respect their chosen height and only sync overlay.
if (userResizedRef.current[fieldId]) {
const currentHeight =
textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX
const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight)
textarea.style.height = `${clampedHeight}px`
if (overlay) {
overlay.style.height = `${clampedHeight}px`
}
return
}
// Auto-resize to fit content only when user hasn't manually resized.
textarea.style.height = 'auto'
const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
textarea.style.height = `${nextHeight}px`
if (overlay) {
overlay.style.height = `${nextHeight}px`
}
}, [])
const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const textarea = textareaRefs.current[fieldId]
if (!textarea) return
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
isResizingRef.current = true
resizeStateRef.current = {
fieldId,
startY: e.clientY,
startHeight,
}
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizingRef.current || !resizeStateRef.current) return
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
const deltaY = moveEvent.clientY - startY
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
const activeTextarea = textareaRefs.current[activeFieldId]
if (activeTextarea) {
activeTextarea.style.height = `${nextHeight}px`
}
const overlay = overlayRefs.current[activeFieldId]
if (overlay) {
overlay.style.height = `${nextHeight}px`
}
}
const handleMouseUp = () => {
if (resizeStateRef.current) {
const { fieldId: activeFieldId } = resizeStateRef.current
userResizedRef.current[activeFieldId] = true
}
isResizingRef.current = false
resizeStateRef.current = null
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [])
useEffect(() => {
currentMessages.forEach((_, index) => {
const fieldId = `message-${index}`
autoResizeTextarea(fieldId)
})
}, [currentMessages, autoResizeTextarea])
return (
<div className='flex w-full flex-col gap-3'>
{currentMessages.map((message, index) => (
<div
key={`message-${index}`}
className={cn(
'relative flex w-full flex-col rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] transition-colors dark:bg-[var(--surface-9)]',
disabled && 'opacity-50'
)}
>
{(() => {
const fieldId = `message-${index}`
const fieldState = subBlockInput.fieldHelpers.getFieldState(fieldId)
const fieldHandlers = subBlockInput.fieldHelpers.createFieldHandlers(
fieldId,
message.content,
(newValue: string) => {
updateMessageContent(index, newValue)
}
)
const handleEnvSelect = subBlockInput.fieldHelpers.createEnvVarSelectHandler(
fieldId,
message.content,
(newValue: string) => {
updateMessageContent(index, newValue)
}
)
const handleTagSelect = subBlockInput.fieldHelpers.createTagSelectHandler(
fieldId,
message.content,
(newValue: string) => {
updateMessageContent(index, newValue)
}
)
const textareaRefObject = {
current: textareaRefs.current[fieldId] ?? null,
} as React.RefObject<HTMLTextAreaElement>
return (
<>
{/* Header with role label and add button */}
<div
className='flex cursor-pointer items-center justify-between px-[8px] pt-[6px]'
onClick={(e) => handleHeaderClick(index, e)}
>
<Popover
open={openPopoverIndex === index}
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
>
<PopoverTrigger asChild>
<button
type='button'
disabled={isPreview || disabled}
className={cn(
'-ml-1.5 -my-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-8)] hover:text-[var(--text-secondary)]',
(isPreview || disabled) &&
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
)}
onClick={(e) => e.stopPropagation()}
aria-label='Select message role'
>
{formatRole(message.role)}
</button>
</PopoverTrigger>
<PopoverContent minWidth={140} align='start'>
<div className='flex flex-col gap-[2px]'>
{(['system', 'user', 'assistant'] as const).map((role) => (
<PopoverItem
key={role}
active={message.role === role}
onClick={() => {
updateMessageRole(index, role)
setOpenPopoverIndex(null)
}}
>
<span>{formatRole(role)}</span>
</PopoverItem>
))}
</div>
</PopoverContent>
</Popover>
{!isPreview && !disabled && (
<div className='flex items-center'>
{currentMessages.length > 1 && (
<>
<Button
variant='ghost'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
deleteMessage(index)
}}
disabled={disabled}
className='-my-1 -mr-1 h-6 w-6 p-0'
aria-label='Delete message'
>
<Trash className='h-3 w-3' />
</Button>
<Button
variant='ghost'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
moveMessageUp(index)
}}
disabled={disabled || index === 0}
className='-my-1 -mr-1 h-6 w-6 p-0'
aria-label='Move message up'
>
<ChevronUp className='h-3 w-3' />
</Button>
<Button
variant='ghost'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
moveMessageDown(index)
}}
disabled={disabled || index === currentMessages.length - 1}
className='-my-1 -mr-1 h-6 w-6 p-0'
aria-label='Move message down'
>
<ChevronDown className='h-3 w-3' />
</Button>
</>
)}
<Button
variant='ghost'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
addMessageAfter(index)
}}
disabled={disabled}
className='-mr-1.5 -my-1 h-6 w-6 p-0'
aria-label='Add message below'
>
<Plus className='h-3.5 w-3.5' />
</Button>
</div>
)}
</div>
{/* Content Input with overlay for variable highlighting */}
<div className='relative w-full'>
<textarea
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
rows={3}
placeholder='Enter message content...'
value={message.content}
onChange={(e) => {
fieldHandlers.onChange(e)
autoResizeTextarea(fieldId)
}}
onKeyDown={fieldHandlers.onKeyDown}
onDrop={fieldHandlers.onDrop}
onDragOver={fieldHandlers.onDragOver}
onScroll={(e) => {
const overlay = overlayRefs.current[fieldId]
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}}
disabled={isPreview || disabled}
/>
<div
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
className='pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
>
{formatDisplayText(message.content, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
{/* Env var dropdown for this message */}
<EnvVarDropdown
visible={fieldState.showEnvVars && !isPreview && !disabled}
onSelect={handleEnvSelect}
searchTerm={fieldState.searchTerm}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
workspaceId={subBlockInput.workspaceId}
maxHeight='192px'
inputRef={textareaRefObject}
/>
{/* Tag dropdown for this message */}
<TagDropdown
visible={fieldState.showTags && !isPreview && !disabled}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={textareaRefObject}
/>
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => {
e.preventDefault()
}}
>
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
</div>
)}
</div>
</>
)
})()}
</div>
))}
</div>
)
}

View File

@@ -1,18 +1,17 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Code, FileJson, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button as EmcnButton,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -1212,26 +1211,35 @@ try {
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this tool?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the tool and remove it from
any workflows that are using it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
<Modal open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<ModalContent>
<ModalHeader>
<ModalTitle>Are you sure you want to delete this tool?</ModalTitle>
<ModalDescription>
This will permanently delete the tool and remove it from any workflows that are using
it.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<EmcnButton
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</EmcnButton>
<EmcnButton
onClick={handleDelete}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</EmcnButton>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -29,6 +29,7 @@ import {
McpDynamicArgs,
McpServerSelector,
McpToolSelector,
MessagesInput,
ProjectSelectorInput,
ResponseFormat,
ScheduleSave,
@@ -816,6 +817,18 @@ function SubBlockComponent({
/>
)
case 'messages-input':
return (
<MessagesInput
blockId={blockId}
subBlockId={config.id}
config={config}
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
/>
)
default:
return <div>Unknown input type: {config.type}</div>
}

View File

@@ -97,12 +97,39 @@ const isVariableAssignmentsArray = (
)
}
/**
* Type guard for agent messages array
*/
const isMessagesArray = (value: unknown): value is Array<{ role: string; content: string }> => {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(item) =>
typeof item === 'object' &&
item !== null &&
'role' in item &&
'content' in item &&
typeof item.role === 'string' &&
typeof item.content === 'string'
)
)
}
/**
* Formats a subblock value for display, intelligently handling nested objects and arrays.
*/
const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
if (isMessagesArray(value)) {
const firstMessage = value[0]
if (!firstMessage?.content || firstMessage.content.trim() === '') return '-'
const content = firstMessage.content.trim()
// Show first 50 characters of the first message content
return content.length > 50 ? `${content.slice(0, 50)}...` : content
}
if (isVariableAssignmentsArray(value)) {
const names = value.map((a) => a.variableName).filter((name): name is string => !!name)
if (names.length === 0) return '-'
@@ -332,7 +359,10 @@ const SubBlockRow = ({
return (
<div className='flex items-center gap-[8px]'>
<span className='min-w-0 truncate text-[14px] text-[var(--text-tertiary)]' title={title}>
<span
className='min-w-0 truncate text-[14px] text-[var(--text-tertiary)] capitalize'
title={title}
>
{title}
</span>
{displayValue !== undefined && (

View File

@@ -521,9 +521,10 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
</div>
<ModalFooter className='flex'>
<ModalFooter>
<Button
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => {
setIsCreateDialogOpen(false)
setNewKeyName('')
@@ -535,15 +536,15 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<Button
type='button'
variant='primary'
className='h-[32px] px-[12px]'
onClick={handleCreateKey}
className='h-9 w-full rounded-[8px] disabled:cursor-not-allowed disabled:opacity-50'
disabled={
!newKeyName.trim() ||
createApiKeyMutation.isPending ||
(keyType === 'workspace' && !canManageWorkspaceKeys)
}
>
Create {keyType === 'workspace' ? 'Workspace' : 'Personal'} Key
{createApiKeyMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -3,18 +3,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Search, Share2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Tooltip } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Tooltip,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import {
usePersonalEnvironment,
@@ -732,46 +731,44 @@ export function EnvironmentVariables({
</div>
</div>
<AlertDialog open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<ModalContent>
<ModalHeader>
<ModalTitle>Unsaved Changes</ModalTitle>
<ModalDescription>
{hasConflicts
? 'You have unsaved changes, but conflicts must be resolved before saving. You can discard your changes to close the modal.'
: 'You have unsaved changes. Do you want to save them before closing?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
onClick={handleCancel}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
Discard Changes
</AlertDialogCancel>
</Button>
{hasConflicts ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertDialogAction
<Button
disabled={true}
className='h-9 w-full cursor-not-allowed rounded-[8px] bg-primary text-white opacity-50 transition-all duration-200'
variant='primary'
className='h-[32px] cursor-not-allowed px-[12px] opacity-50'
>
Save Changes
</AlertDialogAction>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>
</Tooltip.Root>
) : (
<AlertDialogAction
onClick={handleSave}
className='h-9 w-full rounded-[8px] bg-primary text-white transition-all duration-200 hover:bg-primary/90'
>
<Button onClick={handleSave} variant='primary' className='h-[32px] px-[12px]'>
Save Changes
</AlertDialogAction>
</Button>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -3,16 +3,14 @@
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
@@ -235,25 +233,25 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
</Button>
</div>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Modal open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<ModalContent>
<ModalHeader>
<ModalTitle>
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} subscription?
</AlertDialogTitle>
<AlertDialogDescription>
</ModalTitle>
<ModalDescription>
{isCancelAtPeriodEnd
? '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-red-500 dark:text-red-500'>
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
{!isCancelAtPeriodEnd && (
<div className='py-2'>
@@ -268,41 +266,42 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
</div>
)}
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
disabled={isLoading}
>
{isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'}
</AlertDialogCancel>
</Button>
{(() => {
const subscriptionStatus = currentSubscriptionStatus
if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) {
return (
<AlertDialogAction
<Button
onClick={handleKeep}
className='h-9 w-full rounded-[8px] bg-green-500 text-white transition-all duration-200 hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600'
className='h-[32px] bg-green-500 px-[12px] text-white hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600'
disabled={isLoading}
>
{isLoading ? 'Restoring...' : 'Restore Subscription'}
</AlertDialogAction>
</Button>
)
}
return (
<AlertDialogAction
<Button
onClick={handleCancel}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
disabled={isLoading}
>
{isLoading ? 'Redirecting...' : 'Continue'}
</AlertDialogAction>
</Button>
)
})()}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,12 +1,12 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
interface RemoveMemberDialogProps {
open: boolean
@@ -30,17 +30,19 @@ export function RemoveMemberDialog({
isSelfRemoval = false,
}: RemoveMemberDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</DialogTitle>
<DialogDescription>
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>
<ModalTitle>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</ModalTitle>
<ModalDescription>
{isSelfRemoval
? 'Are you sure you want to leave this organization? You will lose access to all team resources.'
: `Are you sure you want to remove ${memberName} from the team?`}{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</DialogDescription>
</DialogHeader>
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
{!isSelfRemoval && (
<div className='py-4'>
@@ -62,19 +64,18 @@ export function RemoveMemberDialog({
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={onCancel} className='h-9 rounded-[8px]'>
<ModalFooter>
<Button variant='outline' onClick={onCancel} className='h-[32px] px-[12px]'>
Cancel
</Button>
<Button
variant='destructive'
onClick={() => onConfirmRemove(shouldReduceSeats)}
className='h-9 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isSelfRemoval ? 'Leave Organization' : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -2,6 +2,15 @@
import { useEffect, useState } from 'react'
import { Eye, MoreHorizontal, Plus, Trash2, X } from 'lucide-react'
import {
Button as EmcnButton,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import {
Button,
DropdownMenu,
@@ -18,10 +27,8 @@ import {
} from '@/components/ui'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
@@ -441,17 +448,17 @@ export function KnowledgeBaseTags({ knowledgeBaseId }: KnowledgeBaseTagsProps) {
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
<AlertDialogDescription asChild>
<Modal open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete Tag</ModalTitle>
<ModalDescription asChild>
<div>
<div className='mb-2'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
<span className='text-red-500 dark:text-red-500'>
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</div>
@@ -467,22 +474,27 @@ export function KnowledgeBaseTags({ knowledgeBaseId }: KnowledgeBaseTagsProps) {
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<EmcnButton
variant='outline'
className='h-[32px] px-[12px]'
disabled={isDeleting}
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</AlertDialogCancel>
<Button
</EmcnButton>
<EmcnButton
onClick={confirmDeleteTag}
disabled={isDeleting}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isDeleting ? 'Deleting...' : 'Delete Tag'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</EmcnButton>
</ModalFooter>
</ModalContent>
</Modal>
{/* View Documents Dialog */}
<AlertDialog open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>

View File

@@ -3,17 +3,15 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Download, LogOut, Pencil, Plus, Send, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Button as UIButton } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
@@ -84,6 +82,8 @@ export function WorkspaceSelector({
} | null>(null)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [workspaceToDelete, setWorkspaceToDelete] = useState<Workspace | null>(null)
const [isLeaveDialogOpen, setIsLeaveDialogOpen] = useState(false)
const [workspaceToLeave, setWorkspaceToLeave] = useState<Workspace | null>(null)
const [isExporting, setIsExporting] = useState(false)
// Refs
@@ -509,7 +509,6 @@ export function WorkspaceSelector({
activeWorkspace?.id === workspace.id && (
<Button
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
handleExportWorkspace()
@@ -526,7 +525,6 @@ export function WorkspaceSelector({
{!isEditing && isHovered && workspace.permissions === 'admin' && (
<Button
variant='ghost'
size='icon'
onClick={(e) => handleStartEdit(workspace, e)}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
@@ -536,72 +534,29 @@ export function WorkspaceSelector({
{/* Leave Workspace - for non-admin users */}
{workspace.permissions !== 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => e.stopPropagation()}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<LogOut className='!h-3.5 !w-3.5' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Leave workspace?</AlertDialogTitle>
<AlertDialogDescription>
Leaving this workspace will remove your access to all associated
workflows, logs, and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name <strong>{workspace.name}</strong> to confirm.
</p>
<Input
value={leaveConfirmationName}
onChange={(e) => setLeaveConfirmationName(e.target.value)}
placeholder={workspace.name}
className='h-9'
/>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setLeaveConfirmationName('')}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
confirmLeaveWorkspace(workspace)
setLeaveConfirmationName('')
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isLeaving || leaveConfirmationName !== workspace.name}
>
{isLeaving ? 'Leaving...' : 'Leave'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<>
<UIButton
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
setWorkspaceToLeave(workspace)
setIsLeaveDialogOpen(true)
}}
className={cn(
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<LogOut className='!h-3.5 !w-3.5' />
</UIButton>
</>
)}
{/* Delete Workspace - for admin users */}
{workspace.permissions === 'admin' && (
<Button
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
setWorkspaceToDelete(workspace)
@@ -670,15 +625,15 @@ export function WorkspaceSelector({
</div>
{/* Centralized Delete Workspace Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={handleDialogClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Modal open={isDeleteDialogOpen} onOpenChange={handleDialogClose}>
<ModalContent>
<ModalHeader>
<ModalTitle>
{showTemplateChoice
? 'Delete workspace with published templates?'
: 'Delete workspace?'}
</AlertDialogTitle>
<AlertDialogDescription>
</ModalTitle>
<ModalDescription>
{showTemplateChoice ? (
<>
This workspace contains {templatesInfo?.count} published template
@@ -697,19 +652,19 @@ export function WorkspaceSelector({
<>
Deleting this workspace will permanently remove all associated workflows, logs,
and knowledge bases.{' '}
<span className='text-red-500 dark:text-red-500'>
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
{showTemplateChoice ? (
<div className='flex gap-2 py-2'>
<Button
onClick={() => handleTemplateAction('keep')}
className='h-9 flex-1 rounded-[8px]'
className='h-[32px] flex-1 px-[12px]'
variant='outline'
disabled={isDeleting}
>
@@ -717,7 +672,7 @@ export function WorkspaceSelector({
</Button>
<Button
onClick={() => handleTemplateAction('delete')}
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] flex-1 bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Templates'}
@@ -739,10 +694,10 @@ export function WorkspaceSelector({
)}
{!showTemplateChoice && (
<AlertDialogFooter className='flex'>
<ModalFooter>
<Button
variant='outline'
className='h-9 w-full rounded-[8px]'
className='h-[32px] px-[12px]'
onClick={() => {
resetDeleteState()
setIsDeleteDialogOpen(false)
@@ -755,7 +710,7 @@ export function WorkspaceSelector({
e.preventDefault()
handleDeleteClick()
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
disabled={
isDeleting ||
deleteConfirmationName !== workspaceToDelete?.name ||
@@ -764,10 +719,75 @@ export function WorkspaceSelector({
>
{isDeleting ? 'Deleting...' : isCheckingTemplates ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</ModalFooter>
)}
</AlertDialogContent>
</AlertDialog>
</ModalContent>
</Modal>
{/* Leave Workspace Modal */}
<Modal
open={isLeaveDialogOpen}
onOpenChange={(open) => {
setIsLeaveDialogOpen(open)
if (!open) {
setLeaveConfirmationName('')
setWorkspaceToLeave(null)
}
}}
>
<ModalContent>
<ModalHeader>
<ModalTitle>Leave workspace?</ModalTitle>
<ModalDescription>
Leaving this workspace will remove your access to all associated workflows, logs, and
knowledge bases.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the workspace name <strong>{workspaceToLeave?.name}</strong> to confirm.
</p>
<Input
value={leaveConfirmationName}
onChange={(e) => setLeaveConfirmationName(e.target.value)}
placeholder={workspaceToLeave?.name}
className='h-9'
/>
</div>
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => {
setIsLeaveDialogOpen(false)
setLeaveConfirmationName('')
setWorkspaceToLeave(null)
}}
>
Cancel
</Button>
<Button
onClick={() => {
if (workspaceToLeave) {
confirmLeaveWorkspace(workspaceToLeave)
setLeaveConfirmationName('')
setIsLeaveDialogOpen(false)
setWorkspaceToLeave(null)
}
}}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
disabled={isLeaving || leaveConfirmationName !== workspaceToLeave?.name}
>
{isLeaving ? 'Leaving...' : 'Leave'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Invite Modal */}
<InviteModal

View File

@@ -75,81 +75,10 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
icon: AgentIcon,
subBlocks: [
{
id: 'systemPrompt',
title: 'System Prompt',
type: 'long-input',
placeholder: 'Enter system prompt...',
rows: 5,
wandConfig: {
enabled: true,
maintainHistory: true, // Enable conversation history for iterative improvements
prompt: `You are an expert system prompt engineer. Create a system prompt based on the user's request.
### CONTEXT
{context}
### INSTRUCTIONS
Write a system prompt following best practices. Match the complexity level the user requests.
### CORE PRINCIPLES
1. **Role Definition**: Start with "You are..." to establish identity and function
2. **Direct Commands**: Use action verbs like "Analyze", "Generate", "Classify"
3. **Be Specific**: Include output format, quality standards, behaviors, target audience
4. **Clear Boundaries**: Define focus areas and priorities
5. **Examples**: Add concrete examples when helpful
### STRUCTURE
- **Primary Role**: Clear identity statement
- **Core Capabilities**: Main functions and expertise
- **Behavioral Guidelines**: Task approach and interaction style
- **Output Requirements**: Format, style, quality expectations
- **Tool Integration**: Specific tool usage instructions
### TOOL INTEGRATION
When users mention tools, include explicit instructions:
- **Web Search**: "Use Exa to gather current information from authoritative sources"
- **Communication**: "Send messages via Slack/Discord/Teams with appropriate tone"
- **Email**: "Compose emails through Gmail with professional formatting"
- **Data**: "Query databases, analyze spreadsheets, call APIs as needed"
### EXAMPLES
**Simple**: "Create a customer service agent"
→ You are a professional customer service representative. Respond to inquiries about orders, returns, and products with empathy and efficiency. Maintain a helpful tone while providing accurate information and clear next steps.
**Detailed**: "Build a research assistant for market analysis"
→ You are an expert market research analyst specializing in competitive intelligence and industry trends. Conduct thorough market analysis using systematic methodologies.
Use Exa to gather information from industry sources, financial reports, and market research firms. Cross-reference findings across multiple credible sources.
For each request, follow this structure:
1. Define research scope and key questions
2. Identify market segments and competitors
3. Gather quantitative data (market size, growth rates)
4. Collect qualitative insights (trends, consumer behavior)
5. Synthesize findings into actionable recommendations
Present findings in executive-ready formats with source citations, highlight key insights, and provide specific recommendations with rationale.
### FINAL INSTRUCTION
Create a system prompt appropriately detailed for the request, using clear language and relevant tool instructions.`,
placeholder: 'Describe the AI agent you want to create...',
generationType: 'system-prompt',
},
},
{
id: 'userPrompt',
title: 'User Prompt',
type: 'long-input',
placeholder: 'Enter context or user message...',
rows: 5,
},
{
id: 'memories',
title: 'Memories',
type: 'short-input',
placeholder: 'Connect memory block output...',
mode: 'advanced',
id: 'messages',
// title: 'Messages',
type: 'messages-input',
placeholder: 'Enter messages...',
},
{
id: 'model',
@@ -170,6 +99,139 @@ Create a system prompt appropriately detailed for the request, using clear langu
})
},
},
{
id: 'reasoningEffort',
title: 'Reasoning Effort',
type: 'dropdown',
placeholder: 'Select reasoning effort...',
options: [
{ label: 'none', id: 'none' },
{ label: 'minimal', id: 'minimal' },
{ label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' },
],
value: () => 'medium',
condition: {
field: 'model',
value: MODELS_WITH_REASONING_EFFORT,
},
},
{
id: 'verbosity',
title: 'Verbosity',
type: 'dropdown',
placeholder: 'Select verbosity...',
options: [
{ label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' },
],
value: () => 'medium',
condition: {
field: 'model',
value: MODELS_WITH_VERBOSITY,
},
},
{
id: 'azureEndpoint',
title: 'Azure OpenAI Endpoint',
type: 'short-input',
password: true,
placeholder: 'https://your-resource.openai.azure.com',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'azureApiVersion',
title: 'Azure API Version',
type: 'short-input',
placeholder: '2024-07-01-preview',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'tools',
title: 'Tools',
type: 'tool-input',
defaultValue: [],
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
// Hide API key for hosted models and Ollama models
condition: isHosted
? {
field: 'model',
value: getHostedModels(),
not: true, // Show for all models EXCEPT those listed
}
: () => ({
field: 'model',
value: getCurrentOllamaModels(),
not: true, // Show for all models EXCEPT Ollama models
}),
},
{
id: 'memoryType',
title: 'Memory',
type: 'dropdown',
placeholder: 'Select memory...',
options: [
{ label: 'None', id: 'none' },
{ label: 'Conversation', id: 'conversation' },
{ label: 'Sliding window (messages)', id: 'sliding_window' },
{ label: 'Sliding window (tokens)', id: 'sliding_window_tokens' },
],
defaultValue: 'none',
},
{
id: 'conversationId',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'e.g., user-123, session-abc, customer-456',
required: {
field: 'memoryType',
value: ['conversation', 'sliding_window', 'sliding_window_tokens'],
},
condition: {
field: 'memoryType',
value: ['conversation', 'sliding_window', 'sliding_window_tokens'],
},
},
{
id: 'slidingWindowSize',
title: 'Sliding Window Size',
type: 'short-input',
placeholder: 'Enter number of messages (e.g., 10)...',
condition: {
field: 'memoryType',
value: ['sliding_window'],
},
},
{
id: 'slidingWindowTokens',
title: 'Max Tokens',
type: 'short-input',
placeholder: 'Enter max tokens (e.g., 4000)...',
condition: {
field: 'memoryType',
value: ['sliding_window_tokens'],
},
},
{
id: 'temperature',
title: 'Temperature',
@@ -204,90 +266,6 @@ Create a system prompt appropriately detailed for the request, using clear langu
})(),
}),
},
{
id: 'reasoningEffort',
title: 'Reasoning Effort',
type: 'dropdown',
placeholder: 'Select reasoning effort...',
options: [
{ label: 'none', id: 'none' },
{ label: 'minimal', id: 'minimal' },
{ label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' },
],
value: () => 'medium',
condition: {
field: 'model',
value: MODELS_WITH_REASONING_EFFORT,
},
},
{
id: 'verbosity',
title: 'Verbosity',
type: 'dropdown',
placeholder: 'Select verbosity...',
options: [
{ label: 'low', id: 'low' },
{ label: 'medium', id: 'medium' },
{ label: 'high', id: 'high' },
],
value: () => 'medium',
condition: {
field: 'model',
value: MODELS_WITH_VERBOSITY,
},
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
// Hide API key for hosted models and Ollama models
condition: isHosted
? {
field: 'model',
value: getHostedModels(),
not: true, // Show for all models EXCEPT those listed
}
: () => ({
field: 'model',
value: getCurrentOllamaModels(),
not: true, // Show for all models EXCEPT Ollama models
}),
},
{
id: 'azureEndpoint',
title: 'Azure OpenAI Endpoint',
type: 'short-input',
password: true,
placeholder: 'https://your-resource.openai.azure.com',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'azureApiVersion',
title: 'Azure API Version',
type: 'short-input',
placeholder: '2024-07-01-preview',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'tools',
title: 'Tools',
type: 'tool-input',
defaultValue: [],
},
{
id: 'responseFormat',
title: 'Response Format',
@@ -450,9 +428,31 @@ Example 3 (Array Input):
},
},
inputs: {
systemPrompt: { type: 'string', description: 'Initial system instructions' },
userPrompt: { type: 'string', description: 'User message or context' },
memories: { type: 'json', description: 'Agent memory data' },
messages: {
type: 'json',
description:
'Array of message objects with role and content: [{ role: "system", content: "..." }, { role: "user", content: "..." }]',
},
memoryType: {
type: 'string',
description:
'Type of memory to use: none, conversation, sliding_window, or sliding_window_tokens',
},
conversationId: {
type: 'string',
description:
'Specific conversation ID to retrieve memories from (when memoryType is conversation_id)',
},
slidingWindowSize: {
type: 'string',
description:
'Number of recent messages to include (when memoryType is sliding_window, e.g., "10")',
},
slidingWindowTokens: {
type: 'string',
description:
'Maximum number of tokens for token-based sliding window memory (when memoryType is sliding_window_tokens, e.g., "4000")',
},
model: { type: 'string', description: 'AI model to use' },
apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },

View File

@@ -31,10 +31,10 @@ export const MemoryBlock: BlockConfig = {
value: () => 'add',
},
{
id: 'id',
title: 'ID',
id: 'conversationId',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'Enter memory identifier',
placeholder: 'Enter conversation ID (e.g., user-123)',
condition: {
field: 'operation',
value: 'add',
@@ -42,26 +42,81 @@ export const MemoryBlock: BlockConfig = {
required: true,
},
{
id: 'id',
title: 'ID',
id: 'blockId',
title: 'Block ID',
type: 'short-input',
placeholder: 'Enter memory identifier to retrieve',
placeholder: 'Enter block ID (optional, defaults to current block)',
condition: {
field: 'operation',
value: 'add',
},
required: false,
},
{
id: 'conversationId',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'Enter conversation ID (e.g., user-123)',
condition: {
field: 'operation',
value: 'get',
},
required: true,
required: false,
},
{
id: 'id',
title: 'ID',
id: 'blockId',
title: 'Block ID',
type: 'short-input',
placeholder: 'Enter memory identifier to delete',
placeholder: 'Enter block ID (optional)',
condition: {
field: 'operation',
value: 'get',
},
required: false,
},
{
id: 'blockName',
title: 'Block Name',
type: 'short-input',
placeholder: 'Enter block name (optional)',
condition: {
field: 'operation',
value: 'get',
},
required: false,
},
{
id: 'conversationId',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'Enter conversation ID (e.g., user-123)',
condition: {
field: 'operation',
value: 'delete',
},
required: true,
required: false,
},
{
id: 'blockId',
title: 'Block ID',
type: 'short-input',
placeholder: 'Enter block ID (optional)',
condition: {
field: 'operation',
value: 'delete',
},
required: false,
},
{
id: 'blockName',
title: 'Block Name',
type: 'short-input',
placeholder: 'Enter block name (optional)',
condition: {
field: 'operation',
value: 'delete',
},
required: false,
},
{
id: 'role',
@@ -110,24 +165,16 @@ export const MemoryBlock: BlockConfig = {
}
},
params: (params: Record<string, any>) => {
// Create detailed error information for any missing required fields
const errors: string[] = []
if (!params.operation) {
errors.push('Operation is required')
}
if (
params.operation === 'add' ||
params.operation === 'get' ||
params.operation === 'delete'
) {
if (!params.id) {
errors.push(`Memory ID is required for ${params.operation} operation`)
}
}
if (params.operation === 'add') {
if (!params.conversationId) {
errors.push('Conversation ID is required for add operation')
}
if (!params.role) {
errors.push('Role is required for agent memory')
}
@@ -136,51 +183,60 @@ export const MemoryBlock: BlockConfig = {
}
}
// Throw error if any required fields are missing
if (params.operation === 'get' || params.operation === 'delete') {
if (!params.conversationId && !params.blockId && !params.blockName) {
errors.push(
`At least one of conversationId, blockId, or blockName is required for ${params.operation} operation`
)
}
}
if (errors.length > 0) {
throw new Error(`Memory Block Error: ${errors.join(', ')}`)
}
// Base result object
const baseResult: Record<string, any> = {}
// For add operation
if (params.operation === 'add') {
const result: Record<string, any> = {
...baseResult,
id: params.id,
type: 'agent', // Always agent type
conversationId: params.conversationId,
role: params.role,
content: params.content,
}
if (params.blockId) {
result.blockId = params.blockId
}
return result
}
// For get operation
if (params.operation === 'get') {
return {
...baseResult,
id: params.id,
}
const result: Record<string, any> = { ...baseResult }
if (params.conversationId) result.conversationId = params.conversationId
if (params.blockId) result.blockId = params.blockId
if (params.blockName) result.blockName = params.blockName
return result
}
// For delete operation
if (params.operation === 'delete') {
return {
...baseResult,
id: params.id,
}
const result: Record<string, any> = { ...baseResult }
if (params.conversationId) result.conversationId = params.conversationId
if (params.blockId) result.blockId = params.blockId
if (params.blockName) result.blockName = params.blockName
return result
}
// For getAll operation
return baseResult
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
id: { type: 'string', description: 'Memory identifier' },
id: { type: 'string', description: 'Memory identifier (for add operation)' },
conversationId: { type: 'string', description: 'Conversation identifier' },
blockId: { type: 'string', description: 'Block identifier' },
blockName: { type: 'string', description: 'Block name' },
role: { type: 'string', description: 'Agent role' },
content: { type: 'string', description: 'Memory content' },
},

View File

@@ -71,6 +71,7 @@ export type SubBlockType =
| 'file-upload' // File uploader
| 'input-mapping' // Map parent variables to child workflow input schema
| 'variables-input' // Variable assignments for updating workflow variables
| 'messages-input' // Multiple message inputs with role and content for LLM message history
| 'text' // Read-only text display
/**

View File

@@ -201,6 +201,13 @@ export interface PopoverContentProps
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>,
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding'
> {
/**
* When true, renders the popover content inline instead of in a portal.
* Useful when used inside other portalled components (e.g. dialogs)
* where additional portals can interfere with scroll locking behavior.
* @default false
*/
disablePortal?: boolean
/**
* Maximum height for the popover content in pixels
*/
@@ -255,6 +262,7 @@ const PopoverContent = React.forwardRef<
(
{
className,
disablePortal = false,
style,
children,
maxHeight,
@@ -281,41 +289,67 @@ const PopoverContent = React.forwardRef<
style?.maxWidth !== undefined ||
style?.width !== undefined
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
side={side}
align={align}
sideOffset={effectiveSideOffset}
collisionPadding={collisionPadding}
avoidCollisions={true}
sticky='partial'
{...restProps}
className={cn(
'z-[10000001] flex flex-col overflow-auto rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]',
// If width is constrained by the caller (prop or style), ensure inner flexible text truncates by default,
// and also truncate section headers.
hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate',
className
)}
style={{
maxHeight: `${maxHeight || 400}px`,
maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
// Only enforce default min width when the user hasn't set width constraints
minWidth:
minWidth !== undefined
? `${minWidth}px`
: hasUserWidthConstraint
? undefined
: '160px',
...style,
}}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const container = event.currentTarget
if (!container) return
const { scrollHeight, clientHeight, scrollTop } = container
if (scrollHeight <= clientHeight) {
return
}
const deltaY = event.deltaY
const isScrollingDown = deltaY > 0
const isAtTop = scrollTop === 0
const isAtBottom = scrollTop + clientHeight >= scrollHeight
// If we're at the boundary and user keeps scrolling in that direction,
// let the event bubble so parent scroll containers can handle it.
if ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) {
return
}
// Otherwise, consume the wheel event and manually scroll the popover content.
event.preventDefault()
container.scrollTop += deltaY
}
const content = (
<PopoverPrimitive.Content
ref={ref}
side={side}
align={align}
sideOffset={effectiveSideOffset}
collisionPadding={collisionPadding}
avoidCollisions={true}
sticky='partial'
onWheel={handleWheel}
{...restProps}
className={cn(
'z-[10000001] flex flex-col overflow-auto rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]',
// If width is constrained by the caller (prop or style), ensure inner flexible text truncates by default,
// and also truncate section headers.
hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate',
className
)}
style={{
maxHeight: `${maxHeight || 400}px`,
maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
// Only enforce default min width when the user hasn't set width constraints
minWidth:
minWidth !== undefined ? `${minWidth}px` : hasUserWidthConstraint ? undefined : '160px',
...style,
}}
>
{children}
</PopoverPrimitive.Content>
)
if (disablePortal) {
return content
}
return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
}
)

View File

@@ -1145,11 +1145,13 @@ describe('AgentBlockHandler', () => {
expect(systemMessages[0].content).toBe('You are a helpful assistant.')
})
it('should prioritize explicit systemPrompt over system messages in memories', async () => {
it('should prioritize messages array system message over system messages in memories', async () => {
const inputs = {
model: 'gpt-4o',
systemPrompt: 'You are a helpful assistant.',
userPrompt: 'What should I do?',
messages: [
{ role: 'system' as const, content: 'You are a helpful assistant.' },
{ role: 'user' as const, content: 'What should I do?' },
],
memories: [
{ role: 'system', content: 'Old system message from memories.' },
{ role: 'user', content: 'Hello!' },
@@ -1167,31 +1169,30 @@ describe('AgentBlockHandler', () => {
// Verify messages were built correctly
expect(requestBody.messages).toBeDefined()
expect(requestBody.messages.length).toBe(4) // explicit system + 2 non-system memories + user prompt
expect(requestBody.messages.length).toBe(5) // memory system + 2 non-system memories + 2 from messages array
// Check only one system message exists and it's the explicit one
const systemMessages = requestBody.messages.filter((msg: any) => msg.role === 'system')
expect(systemMessages.length).toBe(1)
expect(systemMessages[0].content).toBe('You are a helpful assistant.')
// Verify the explicit system prompt is first
// All messages should be present (memories first, then messages array)
// Memory messages come first
expect(requestBody.messages[0].role).toBe('system')
expect(requestBody.messages[0].content).toBe('You are a helpful assistant.')
// Verify conversation order is preserved
expect(requestBody.messages[0].content).toBe('Old system message from memories.')
expect(requestBody.messages[1].role).toBe('user')
expect(requestBody.messages[1].content).toBe('Hello!')
expect(requestBody.messages[2].role).toBe('assistant')
expect(requestBody.messages[2].content).toBe('Hi there!')
expect(requestBody.messages[3].role).toBe('user')
expect(requestBody.messages[3].content).toBe('What should I do?')
// Then messages array
expect(requestBody.messages[3].role).toBe('system')
expect(requestBody.messages[3].content).toBe('You are a helpful assistant.')
expect(requestBody.messages[4].role).toBe('user')
expect(requestBody.messages[4].content).toBe('What should I do?')
})
it('should handle multiple system messages in memories with explicit systemPrompt', async () => {
it('should handle multiple system messages in memories with messages array', async () => {
const inputs = {
model: 'gpt-4o',
systemPrompt: 'You are a helpful assistant.',
userPrompt: 'Continue our conversation.',
messages: [
{ role: 'system' as const, content: 'You are a helpful assistant.' },
{ role: 'user' as const, content: 'Continue our conversation.' },
],
memories: [
{ role: 'system', content: 'First system message.' },
{ role: 'user', content: 'Hello!' },
@@ -1211,22 +1212,23 @@ describe('AgentBlockHandler', () => {
// Verify messages were built correctly
expect(requestBody.messages).toBeDefined()
expect(requestBody.messages.length).toBe(4) // explicit system + 2 non-system memories + user prompt
expect(requestBody.messages.length).toBe(7) // 5 memory messages (3 system + 2 conversation) + 2 from messages array
// Check only one system message exists and message order is preserved
const systemMessages = requestBody.messages.filter((msg: any) => msg.role === 'system')
expect(systemMessages.length).toBe(1)
expect(systemMessages[0].content).toBe('You are a helpful assistant.')
// Verify conversation flow is preserved
// All messages should be present in order
expect(requestBody.messages[0].role).toBe('system')
expect(requestBody.messages[0].content).toBe('You are a helpful assistant.')
expect(requestBody.messages[0].content).toBe('First system message.')
expect(requestBody.messages[1].role).toBe('user')
expect(requestBody.messages[1].content).toBe('Hello!')
expect(requestBody.messages[2].role).toBe('assistant')
expect(requestBody.messages[2].content).toBe('Hi there!')
expect(requestBody.messages[3].role).toBe('user')
expect(requestBody.messages[3].content).toBe('Continue our conversation.')
expect(requestBody.messages[2].role).toBe('system')
expect(requestBody.messages[2].content).toBe('Second system message.')
expect(requestBody.messages[3].role).toBe('assistant')
expect(requestBody.messages[3].content).toBe('Hi there!')
expect(requestBody.messages[4].role).toBe('system')
expect(requestBody.messages[4].content).toBe('Third system message.')
expect(requestBody.messages[5].role).toBe('system')
expect(requestBody.messages[5].content).toBe('You are a helpful assistant.')
expect(requestBody.messages[6].role).toBe('user')
expect(requestBody.messages[6].content).toBe('Continue our conversation.')
})
it('should preserve multiple system messages when no explicit systemPrompt is provided', async () => {

View File

@@ -3,6 +3,7 @@ import { createMcpToolId } from '@/lib/mcp/utils'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import { AGENT, BlockType, DEFAULTS, HTTP } from '@/executor/consts'
import { memoryService } from '@/executor/handlers/agent/memory'
import type {
AgentInputs,
Message,
@@ -39,7 +40,7 @@ export class AgentBlockHandler implements BlockHandler {
const providerId = getProviderFromModel(model)
const formattedTools = await this.formatTools(ctx, inputs.tools || [])
const streamingConfig = this.getStreamingConfig(ctx, block)
const messages = this.buildMessages(inputs)
const messages = await this.buildMessages(ctx, inputs, block.id)
const providerRequest = this.buildProviderRequest({
ctx,
@@ -52,7 +53,18 @@ export class AgentBlockHandler implements BlockHandler {
streaming: streamingConfig.shouldUseStreaming ?? false,
})
return this.executeProviderRequest(ctx, providerRequest, block, responseFormat)
const result = await this.executeProviderRequest(
ctx,
providerRequest,
block,
responseFormat,
inputs
)
// Auto-persist response to memory if configured
await this.persistResponseToMemory(ctx, inputs, result, block.id)
return result
}
private parseResponseFormat(responseFormat?: string | object): any {
@@ -324,25 +336,80 @@ export class AgentBlockHandler implements BlockHandler {
return { shouldUseStreaming, isBlockSelectedForOutput, hasOutgoingConnections }
}
private buildMessages(inputs: AgentInputs): Message[] | undefined {
if (!inputs.memories && !(inputs.systemPrompt && inputs.userPrompt)) {
return undefined
}
private async buildMessages(
ctx: ExecutionContext,
inputs: AgentInputs,
blockId: string
): Promise<Message[] | undefined> {
const messages: Message[] = []
// 1. Fetch memory history if configured (industry standard: chronological order)
if (inputs.memoryType && inputs.memoryType !== 'none') {
const memoryMessages = await memoryService.fetchMemoryMessages(ctx, inputs, blockId)
messages.push(...memoryMessages)
}
// 2. Process legacy memories (backward compatibility - from Memory block)
if (inputs.memories) {
messages.push(...this.processMemories(inputs.memories))
}
if (inputs.systemPrompt) {
// 3. Add messages array (new approach - from messages-input subblock)
if (inputs.messages && Array.isArray(inputs.messages)) {
const validMessages = inputs.messages.filter(
(msg) =>
msg &&
typeof msg === 'object' &&
'role' in msg &&
'content' in msg &&
['system', 'user', 'assistant'].includes(msg.role)
)
messages.push(...validMessages)
}
// Warn if using both new and legacy input formats
if (
inputs.messages &&
inputs.messages.length > 0 &&
(inputs.systemPrompt || inputs.userPrompt)
) {
logger.warn('Agent block using both messages array and legacy prompts', {
hasMessages: true,
hasSystemPrompt: !!inputs.systemPrompt,
hasUserPrompt: !!inputs.userPrompt,
})
}
// 4. Handle legacy systemPrompt (backward compatibility)
// Only add if no system message exists yet
if (inputs.systemPrompt && !messages.some((m) => m.role === 'system')) {
this.addSystemPrompt(messages, inputs.systemPrompt)
}
// 5. Handle legacy userPrompt (backward compatibility)
if (inputs.userPrompt) {
this.addUserPrompt(messages, inputs.userPrompt)
}
// 6. Persist user message(s) to memory if configured
// This ensures conversation history is complete before agent execution
if (inputs.memoryType && inputs.memoryType !== 'none' && messages.length > 0) {
// Find new user messages that need to be persisted
// (messages added via messages array or userPrompt)
const userMessages = messages.filter((m) => m.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
// Only persist if there's a user message AND it's from userPrompt or messages input
// (not from memory history which was already persisted)
if (
lastUserMessage &&
(inputs.userPrompt || (inputs.messages && inputs.messages.length > 0))
) {
await memoryService.persistUserMessage(ctx, inputs, lastUserMessage, blockId)
}
}
// Return messages or undefined if empty (maintains API compatibility)
return messages.length > 0 ? messages : undefined
}
@@ -382,6 +449,10 @@ export class AgentBlockHandler implements BlockHandler {
return messages
}
/**
* Ensures system message is at position 0 (industry standard)
* Preserves existing system message if already at position 0, otherwise adds/moves it
*/
private addSystemPrompt(messages: Message[], systemPrompt: any) {
let content: string
@@ -395,17 +466,31 @@ export class AgentBlockHandler implements BlockHandler {
}
}
const systemMessages = messages.filter((msg) => msg.role === 'system')
// Find first system message
const firstSystemIndex = messages.findIndex((msg) => msg.role === 'system')
if (systemMessages.length > 0) {
messages.splice(0, 0, { role: 'system', content })
for (let i = messages.length - 1; i >= 1; i--) {
if (messages[i].role === 'system') {
messages.splice(i, 1)
}
}
if (firstSystemIndex === -1) {
// No system message exists - add at position 0
messages.unshift({ role: 'system', content })
} else if (firstSystemIndex === 0) {
// System message already at position 0 - replace it
// Explicit systemPrompt parameter takes precedence over memory/messages
messages[0] = { role: 'system', content }
} else {
messages.splice(0, 0, { role: 'system', content })
// System message exists but not at position 0 - move it to position 0
// and update with new content
messages.splice(firstSystemIndex, 1)
messages.unshift({ role: 'system', content })
}
// Remove any additional system messages (keep only the first one)
for (let i = messages.length - 1; i >= 1; i--) {
if (messages[i].role === 'system') {
messages.splice(i, 1)
logger.warn('Removed duplicate system message from conversation history', {
position: i,
})
}
}
}
@@ -484,7 +569,8 @@ export class AgentBlockHandler implements BlockHandler {
ctx: ExecutionContext,
providerRequest: any,
block: SerializedBlock,
responseFormat: any
responseFormat: any,
inputs: AgentInputs
): Promise<BlockOutput | StreamingExecution> {
const providerId = providerRequest.provider
const model = providerRequest.model
@@ -504,7 +590,14 @@ export class AgentBlockHandler implements BlockHandler {
providerStartTime
)
}
return this.executeBrowserSide(ctx, providerRequest, block, responseFormat, providerStartTime)
return this.executeBrowserSide(
ctx,
providerRequest,
block,
responseFormat,
providerStartTime,
inputs
)
} catch (error) {
this.handleExecutionError(error, providerStartTime, providerId, model, ctx, block)
throw error
@@ -554,7 +647,8 @@ export class AgentBlockHandler implements BlockHandler {
providerRequest: any,
block: SerializedBlock,
responseFormat: any,
providerStartTime: number
providerStartTime: number,
inputs: AgentInputs
) {
const url = buildAPIUrl('/api/providers')
const response = await fetch(url.toString(), {
@@ -580,7 +674,7 @@ export class AgentBlockHandler implements BlockHandler {
const contentType = response.headers.get('Content-Type')
if (contentType?.includes(HTTP.CONTENT_TYPE.EVENT_STREAM)) {
return this.handleStreamingResponse(response, block)
return this.handleStreamingResponse(response, block, ctx, inputs)
}
const result = await response.json()
@@ -589,7 +683,9 @@ export class AgentBlockHandler implements BlockHandler {
private async handleStreamingResponse(
response: Response,
block: SerializedBlock
block: SerializedBlock,
ctx?: ExecutionContext,
inputs?: AgentInputs
): Promise<StreamingExecution> {
const executionDataHeader = response.headers.get('X-Execution-Data')
@@ -597,6 +693,20 @@ export class AgentBlockHandler implements BlockHandler {
try {
const executionData = JSON.parse(executionDataHeader)
// If execution data contains full content, persist to memory
if (ctx && inputs && executionData.output?.content) {
const assistantMessage: Message = {
role: 'assistant',
content: executionData.output.content,
}
// Fire and forget - don't await
memoryService
.persistMemoryMessage(ctx, inputs, assistantMessage, block.id)
.catch((error) =>
logger.error('Failed to persist streaming response to memory:', error)
)
}
return {
stream: response.body!,
execution: {
@@ -696,6 +806,49 @@ export class AgentBlockHandler implements BlockHandler {
}
}
private async persistResponseToMemory(
ctx: ExecutionContext,
inputs: AgentInputs,
result: BlockOutput | StreamingExecution,
blockId: string
): Promise<void> {
// Only persist if memoryType is configured
if (!inputs.memoryType || inputs.memoryType === 'none') {
return
}
try {
// Don't persist streaming responses here - they're handled separately
if (this.isStreamingExecution(result)) {
return
}
// Extract content from regular response
const blockOutput = result as any
const content = blockOutput?.content
if (!content || typeof content !== 'string') {
return
}
const assistantMessage: Message = {
role: 'assistant',
content,
}
await memoryService.persistMemoryMessage(ctx, inputs, assistantMessage, blockId)
logger.debug('Persisted assistant response to memory', {
workflowId: ctx.workflowId,
memoryType: inputs.memoryType,
conversationId: inputs.conversationId,
})
} catch (error) {
logger.error('Failed to persist response to memory:', error)
// Don't throw - memory persistence failure shouldn't break workflow execution
}
}
private processProviderResponse(
response: any,
block: SerializedBlock,

View File

@@ -0,0 +1,277 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Memory } from '@/executor/handlers/agent/memory'
import type { AgentInputs, Message } from '@/executor/handlers/agent/types'
import type { ExecutionContext } from '@/executor/types'
vi.mock('@/lib/logs/console/logger', () => ({
createLogger: () => ({
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/lib/tokenization/estimators', () => ({
getAccurateTokenCount: vi.fn((text: string) => {
return Math.ceil(text.length / 4)
}),
}))
describe('Memory', () => {
let memoryService: Memory
let mockContext: ExecutionContext
beforeEach(() => {
memoryService = new Memory()
mockContext = {
workflowId: 'test-workflow-id',
executionId: 'test-execution-id',
workspaceId: 'test-workspace-id',
} as ExecutionContext
})
describe('applySlidingWindow (message-based)', () => {
it('should keep last N conversation messages', () => {
const messages: Message[] = [
{ role: 'system', content: 'System prompt' },
{ role: 'user', content: 'Message 1' },
{ role: 'assistant', content: 'Response 1' },
{ role: 'user', content: 'Message 2' },
{ role: 'assistant', content: 'Response 2' },
{ role: 'user', content: 'Message 3' },
{ role: 'assistant', content: 'Response 3' },
]
const result = (memoryService as any).applySlidingWindow(messages, '4')
expect(result.length).toBe(5)
expect(result[0].role).toBe('system')
expect(result[0].content).toBe('System prompt')
expect(result[1].content).toBe('Message 2')
expect(result[4].content).toBe('Response 3')
})
it('should preserve only first system message', () => {
const messages: Message[] = [
{ role: 'system', content: 'First system' },
{ role: 'user', content: 'User message' },
{ role: 'system', content: 'Second system' },
{ role: 'assistant', content: 'Assistant message' },
]
const result = (memoryService as any).applySlidingWindow(messages, '10')
const systemMessages = result.filter((m: Message) => m.role === 'system')
expect(systemMessages.length).toBe(1)
expect(systemMessages[0].content).toBe('First system')
})
it('should handle invalid window size', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applySlidingWindow(messages, 'invalid')
expect(result).toEqual(messages)
})
})
describe('applySlidingWindowByTokens (token-based)', () => {
it('should keep messages within token limit', () => {
const messages: Message[] = [
{ role: 'system', content: 'This is a system message' }, // ~6 tokens
{ role: 'user', content: 'Short' }, // ~2 tokens
{ role: 'assistant', content: 'This is a longer response message' }, // ~8 tokens
{ role: 'user', content: 'Another user message here' }, // ~6 tokens
{ role: 'assistant', content: 'Final response' }, // ~3 tokens
]
// Set limit to ~15 tokens - should include last 2-3 messages
const result = (memoryService as any).applySlidingWindowByTokens(messages, '15', 'gpt-4o')
expect(result.length).toBeGreaterThan(0)
expect(result.length).toBeLessThan(messages.length)
// Should include newest messages
expect(result[result.length - 1].content).toBe('Final response')
})
it('should include at least 1 message even if it exceeds limit', () => {
const messages: Message[] = [
{
role: 'user',
content:
'This is a very long message that definitely exceeds our small token limit of just 5 tokens',
},
]
const result = (memoryService as any).applySlidingWindowByTokens(messages, '5', 'gpt-4o')
expect(result.length).toBe(1)
expect(result[0].content).toBe(messages[0].content)
})
it('should preserve first system message and exclude it from token count', () => {
const messages: Message[] = [
{ role: 'system', content: 'A' }, // System message - always preserved
{ role: 'user', content: 'B' }, // ~1 token
{ role: 'assistant', content: 'C' }, // ~1 token
{ role: 'user', content: 'D' }, // ~1 token
]
// Limit to 2 tokens - should fit system message + last 2 conversation messages (D, C)
const result = (memoryService as any).applySlidingWindowByTokens(messages, '2', 'gpt-4o')
// Should have: system message + 2 conversation messages = 3 total
expect(result.length).toBe(3)
expect(result[0].role).toBe('system') // First system message preserved
expect(result[1].content).toBe('C') // Second most recent conversation message
expect(result[2].content).toBe('D') // Most recent conversation message
})
it('should process messages from newest to oldest', () => {
const messages: Message[] = [
{ role: 'user', content: 'Old message' },
{ role: 'assistant', content: 'Old response' },
{ role: 'user', content: 'New message' },
{ role: 'assistant', content: 'New response' },
]
const result = (memoryService as any).applySlidingWindowByTokens(messages, '10', 'gpt-4o')
// Should prioritize newer messages
expect(result[result.length - 1].content).toBe('New response')
})
it('should handle invalid token limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applySlidingWindowByTokens(
messages,
'invalid',
'gpt-4o'
)
expect(result).toEqual(messages) // Should return all messages
})
it('should handle zero or negative token limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result1 = (memoryService as any).applySlidingWindowByTokens(messages, '0', 'gpt-4o')
expect(result1).toEqual(messages)
const result2 = (memoryService as any).applySlidingWindowByTokens(messages, '-5', 'gpt-4o')
expect(result2).toEqual(messages)
})
it('should work with different model names', () => {
const messages: Message[] = [{ role: 'user', content: 'Test message' }]
const result1 = (memoryService as any).applySlidingWindowByTokens(messages, '100', 'gpt-4o')
expect(result1.length).toBe(1)
const result2 = (memoryService as any).applySlidingWindowByTokens(
messages,
'100',
'claude-3-5-sonnet-20241022'
)
expect(result2.length).toBe(1)
const result3 = (memoryService as any).applySlidingWindowByTokens(messages, '100', undefined)
expect(result3.length).toBe(1)
})
it('should handle empty messages array', () => {
const messages: Message[] = []
const result = (memoryService as any).applySlidingWindowByTokens(messages, '100', 'gpt-4o')
expect(result).toEqual([])
})
})
describe('buildMemoryKey', () => {
it('should build correct key with conversationId:blockId format', () => {
const inputs: AgentInputs = {
memoryType: 'conversation',
conversationId: 'emir',
}
const key = (memoryService as any).buildMemoryKey(mockContext, inputs, 'test-block-id')
expect(key).toBe('emir:test-block-id')
})
it('should use same key format regardless of memory type', () => {
const conversationId = 'user-123'
const blockId = 'block-abc'
const conversationKey = (memoryService as any).buildMemoryKey(
mockContext,
{ memoryType: 'conversation', conversationId },
blockId
)
const slidingWindowKey = (memoryService as any).buildMemoryKey(
mockContext,
{ memoryType: 'sliding_window', conversationId },
blockId
)
const slidingTokensKey = (memoryService as any).buildMemoryKey(
mockContext,
{ memoryType: 'sliding_window_tokens', conversationId },
blockId
)
// All should produce the same key - memory type only affects processing
expect(conversationKey).toBe('user-123:block-abc')
expect(slidingWindowKey).toBe('user-123:block-abc')
expect(slidingTokensKey).toBe('user-123:block-abc')
})
it('should throw error for missing conversationId', () => {
const inputs: AgentInputs = {
memoryType: 'conversation',
// conversationId missing
}
expect(() => {
;(memoryService as any).buildMemoryKey(mockContext, inputs, 'test-block-id')
}).toThrow('Conversation ID is required for all memory types')
})
it('should throw error for empty conversationId', () => {
const inputs: AgentInputs = {
memoryType: 'conversation',
conversationId: ' ', // Only whitespace
}
expect(() => {
;(memoryService as any).buildMemoryKey(mockContext, inputs, 'test-block-id')
}).toThrow('Conversation ID is required for all memory types')
})
})
describe('Token-based vs Message-based comparison', () => {
it('should produce different results for same message count limit', () => {
const messages: Message[] = [
{ role: 'user', content: 'A' }, // Short message (~1 token)
{
role: 'assistant',
content: 'This is a much longer response that takes many more tokens',
}, // Long message (~15 tokens)
{ role: 'user', content: 'B' }, // Short message (~1 token)
]
// Message-based: last 2 messages
const messageResult = (memoryService as any).applySlidingWindow(messages, '2')
expect(messageResult.length).toBe(2)
// Token-based: with limit of 10 tokens, might fit all 3 messages or just last 2
const tokenResult = (memoryService as any).applySlidingWindowByTokens(
messages,
'10',
'gpt-4o'
)
// The long message should affect what fits
expect(tokenResult.length).toBeGreaterThanOrEqual(1)
})
})
})

View File

@@ -0,0 +1,663 @@
import { createLogger } from '@/lib/logs/console/logger'
import { getAccurateTokenCount } from '@/lib/tokenization/estimators'
import type { AgentInputs, Message } from '@/executor/handlers/agent/types'
import type { ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
const logger = createLogger('Memory')
/**
* Class for managing agent conversation memory
* Handles fetching and persisting messages to the memory table
*/
export class Memory {
/**
* Fetch messages from memory based on memoryType configuration
*/
async fetchMemoryMessages(
ctx: ExecutionContext,
inputs: AgentInputs,
blockId: string
): Promise<Message[]> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return []
}
if (!ctx.workflowId) {
logger.warn('Cannot fetch memory without workflowId')
return []
}
try {
this.validateInputs(inputs.conversationId)
const memoryKey = this.buildMemoryKey(ctx, inputs, blockId)
let messages = await this.fetchFromMemoryAPI(ctx.workflowId, memoryKey)
switch (inputs.memoryType) {
case 'conversation':
messages = this.applyContextWindowLimit(messages, inputs.model)
break
case 'sliding_window': {
// Default to 10 messages if not specified (matches agent block default)
const windowSize = inputs.slidingWindowSize || '10'
messages = this.applySlidingWindow(messages, windowSize)
break
}
case 'sliding_window_tokens': {
// Default to 4000 tokens if not specified (matches agent block default)
const maxTokens = inputs.slidingWindowTokens || '4000'
messages = this.applySlidingWindowByTokens(messages, maxTokens, inputs.model)
break
}
}
return messages
} catch (error) {
logger.error('Failed to fetch memory messages:', error)
return []
}
}
/**
* Persist assistant response to memory
* Uses atomic append operations to prevent race conditions
*/
async persistMemoryMessage(
ctx: ExecutionContext,
inputs: AgentInputs,
assistantMessage: Message,
blockId: string
): Promise<void> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return
}
if (!ctx.workflowId) {
logger.warn('Cannot persist memory without workflowId')
return
}
try {
this.validateInputs(inputs.conversationId, assistantMessage.content)
const memoryKey = this.buildMemoryKey(ctx, inputs, blockId)
if (inputs.memoryType === 'sliding_window') {
// Default to 10 messages if not specified (matches agent block default)
const windowSize = inputs.slidingWindowSize || '10'
const existingMessages = await this.fetchFromMemoryAPI(ctx.workflowId, memoryKey)
const updatedMessages = [...existingMessages, assistantMessage]
const messagesToPersist = this.applySlidingWindow(updatedMessages, windowSize)
await this.persistToMemoryAPI(ctx.workflowId, memoryKey, messagesToPersist)
} else if (inputs.memoryType === 'sliding_window_tokens') {
// Default to 4000 tokens if not specified (matches agent block default)
const maxTokens = inputs.slidingWindowTokens || '4000'
const existingMessages = await this.fetchFromMemoryAPI(ctx.workflowId, memoryKey)
const updatedMessages = [...existingMessages, assistantMessage]
const messagesToPersist = this.applySlidingWindowByTokens(
updatedMessages,
maxTokens,
inputs.model
)
await this.persistToMemoryAPI(ctx.workflowId, memoryKey, messagesToPersist)
} else {
// Conversation mode: use atomic append for better concurrency
await this.atomicAppendToMemory(ctx.workflowId, memoryKey, assistantMessage)
}
logger.debug('Successfully persisted memory message', {
workflowId: ctx.workflowId,
key: memoryKey,
})
} catch (error) {
logger.error('Failed to persist memory message:', error)
}
}
/**
* Persist user message to memory before agent execution
*/
async persistUserMessage(
ctx: ExecutionContext,
inputs: AgentInputs,
userMessage: Message,
blockId: string
): Promise<void> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return
}
if (!ctx.workflowId) {
logger.warn('Cannot persist user message without workflowId')
return
}
try {
const memoryKey = this.buildMemoryKey(ctx, inputs, blockId)
if (inputs.slidingWindowSize && inputs.memoryType === 'sliding_window') {
const existingMessages = await this.fetchFromMemoryAPI(ctx.workflowId, memoryKey)
const updatedMessages = [...existingMessages, userMessage]
const messagesToPersist = this.applySlidingWindow(updatedMessages, inputs.slidingWindowSize)
await this.persistToMemoryAPI(ctx.workflowId, memoryKey, messagesToPersist)
} else if (inputs.slidingWindowTokens && inputs.memoryType === 'sliding_window_tokens') {
const existingMessages = await this.fetchFromMemoryAPI(ctx.workflowId, memoryKey)
const updatedMessages = [...existingMessages, userMessage]
const messagesToPersist = this.applySlidingWindowByTokens(
updatedMessages,
inputs.slidingWindowTokens,
inputs.model
)
await this.persistToMemoryAPI(ctx.workflowId, memoryKey, messagesToPersist)
} else {
await this.atomicAppendToMemory(ctx.workflowId, memoryKey, userMessage)
}
} catch (error) {
logger.error('Failed to persist user message:', error)
}
}
/**
* Build memory key based on conversationId and blockId
* BlockId provides block-level memory isolation
*/
private buildMemoryKey(_ctx: ExecutionContext, inputs: AgentInputs, blockId: string): string {
const { conversationId } = inputs
if (!conversationId || conversationId.trim() === '') {
throw new Error(
'Conversation ID is required for all memory types. ' +
'Please provide a unique identifier (e.g., user-123, session-abc, customer-456).'
)
}
return `${conversationId}:${blockId}`
}
/**
* Apply sliding window to limit number of conversation messages
*
* System message handling:
* - System messages are excluded from the sliding window count
* - Only the first system message is preserved and placed at the start
* - This ensures system prompts remain available while limiting conversation history
*/
private applySlidingWindow(messages: Message[], windowSize: string): Message[] {
const limit = Number.parseInt(windowSize, 10)
if (Number.isNaN(limit) || limit <= 0) {
logger.warn('Invalid sliding window size, returning all messages', { windowSize })
return messages
}
const systemMessages = messages.filter((msg) => msg.role === 'system')
const conversationMessages = messages.filter((msg) => msg.role !== 'system')
const recentMessages = conversationMessages.slice(-limit)
const firstSystemMessage = systemMessages.length > 0 ? [systemMessages[0]] : []
return [...firstSystemMessage, ...recentMessages]
}
/**
* Apply token-based sliding window to limit conversation by token count
*
* System message handling:
* - For consistency with message-based sliding window, the first system message is preserved
* - System messages are excluded from the token count
* - This ensures system prompts are always available while limiting conversation history
*/
private applySlidingWindowByTokens(
messages: Message[],
maxTokens: string,
model?: string
): Message[] {
const tokenLimit = Number.parseInt(maxTokens, 10)
if (Number.isNaN(tokenLimit) || tokenLimit <= 0) {
logger.warn('Invalid token limit, returning all messages', { maxTokens })
return messages
}
// Separate system messages from conversation messages for consistent handling
const systemMessages = messages.filter((msg) => msg.role === 'system')
const conversationMessages = messages.filter((msg) => msg.role !== 'system')
const result: Message[] = []
let currentTokenCount = 0
// Add conversation messages from most recent backwards
for (let i = conversationMessages.length - 1; i >= 0; i--) {
const message = conversationMessages[i]
const messageTokens = getAccurateTokenCount(message.content, model)
if (currentTokenCount + messageTokens <= tokenLimit) {
result.unshift(message)
currentTokenCount += messageTokens
} else if (result.length === 0) {
logger.warn('Single message exceeds token limit, including anyway', {
messageTokens,
tokenLimit,
messageRole: message.role,
})
result.unshift(message)
currentTokenCount += messageTokens
break
} else {
// Token limit reached, stop processing
break
}
}
logger.debug('Applied token-based sliding window', {
totalMessages: messages.length,
conversationMessages: conversationMessages.length,
includedMessages: result.length,
totalTokens: currentTokenCount,
tokenLimit,
})
// Preserve first system message and prepend to results (consistent with message-based window)
const firstSystemMessage = systemMessages.length > 0 ? [systemMessages[0]] : []
return [...firstSystemMessage, ...result]
}
/**
* Apply context window limit based on model's maximum context window
* Auto-trims oldest conversation messages when approaching the model's context limit
* Uses 90% of context window (10% buffer for response)
* Only applies if model has contextWindow defined and contextInformationAvailable !== false
*/
private applyContextWindowLimit(messages: Message[], model?: string): Message[] {
if (!model) {
return messages
}
let contextWindow: number | undefined
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
if (provider.contextInformationAvailable === false) {
continue
}
const matchesPattern = provider.modelPatterns?.some((pattern) => pattern.test(model))
const matchesModel = provider.models.some((m) => m.id === model)
if (matchesPattern || matchesModel) {
const modelDef = provider.models.find((m) => m.id === model)
if (modelDef?.contextWindow) {
contextWindow = modelDef.contextWindow
break
}
}
}
if (!contextWindow) {
logger.debug('No context window information available for model, skipping auto-trim', {
model,
})
return messages
}
const maxTokens = Math.floor(contextWindow * 0.9)
logger.debug('Applying context window limit', {
model,
contextWindow,
maxTokens,
totalMessages: messages.length,
})
const systemMessages = messages.filter((msg) => msg.role === 'system')
const conversationMessages = messages.filter((msg) => msg.role !== 'system')
// Count tokens used by system messages first
let systemTokenCount = 0
for (const msg of systemMessages) {
systemTokenCount += getAccurateTokenCount(msg.content, model)
}
// Calculate remaining tokens available for conversation messages
const remainingTokens = Math.max(0, maxTokens - systemTokenCount)
if (systemTokenCount >= maxTokens) {
logger.warn('System messages exceed context window limit, including anyway', {
systemTokenCount,
maxTokens,
systemMessageCount: systemMessages.length,
})
return systemMessages
}
const result: Message[] = []
let currentTokenCount = 0
for (let i = conversationMessages.length - 1; i >= 0; i--) {
const message = conversationMessages[i]
const messageTokens = getAccurateTokenCount(message.content, model)
if (currentTokenCount + messageTokens <= remainingTokens) {
result.unshift(message)
currentTokenCount += messageTokens
} else if (result.length === 0) {
logger.warn('Single message exceeds remaining context window, including anyway', {
messageTokens,
remainingTokens,
systemTokenCount,
messageRole: message.role,
})
result.unshift(message)
currentTokenCount += messageTokens
break
} else {
logger.info('Auto-trimmed conversation history to fit context window', {
originalMessages: conversationMessages.length,
trimmedMessages: result.length,
conversationTokens: currentTokenCount,
systemTokens: systemTokenCount,
totalTokens: currentTokenCount + systemTokenCount,
maxTokens,
})
break
}
}
return [...systemMessages, ...result]
}
/**
* Fetch messages from memory API
*/
private async fetchFromMemoryAPI(workflowId: string, key: string): Promise<Message[]> {
try {
const isBrowser = typeof window !== 'undefined'
if (!isBrowser) {
return await this.fetchFromMemoryDirect(workflowId, key)
}
const headers = await buildAuthHeaders()
const url = buildAPIUrl(`/api/memory/${encodeURIComponent(key)}`, { workflowId })
const response = await fetch(url.toString(), {
method: 'GET',
headers,
})
if (!response.ok) {
if (response.status === 404) {
return []
}
throw new Error(`Failed to fetch memory: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to fetch memory')
}
const memoryData = result.data?.data || result.data
if (Array.isArray(memoryData)) {
return memoryData.filter(
(msg) => msg && typeof msg === 'object' && 'role' in msg && 'content' in msg
)
}
return []
} catch (error) {
logger.error('Error fetching from memory API:', error)
return []
}
}
/**
* Direct database access
*/
private async fetchFromMemoryDirect(workflowId: string, key: string): Promise<Message[]> {
try {
const { db } = await import('@sim/db')
const { memory } = await import('@sim/db/schema')
const { and, eq } = await import('drizzle-orm')
const result = await db
.select({
data: memory.data,
})
.from(memory)
.where(and(eq(memory.workflowId, workflowId), eq(memory.key, key)))
.limit(1)
if (result.length === 0) {
return []
}
const memoryData = result[0].data as any
if (Array.isArray(memoryData)) {
return memoryData.filter(
(msg) => msg && typeof msg === 'object' && 'role' in msg && 'content' in msg
)
}
return []
} catch (error) {
logger.error('Error fetching from memory database:', error)
return []
}
}
/**
* Persist messages to memory API
*/
private async persistToMemoryAPI(
workflowId: string,
key: string,
messages: Message[]
): Promise<void> {
try {
const isBrowser = typeof window !== 'undefined'
if (!isBrowser) {
await this.persistToMemoryDirect(workflowId, key, messages)
return
}
const headers = await buildAuthHeaders()
const url = buildAPIUrl('/api/memory')
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: stringifyJSON({
workflowId,
key,
data: messages,
}),
})
if (!response.ok) {
throw new Error(`Failed to persist memory: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to persist memory')
}
} catch (error) {
logger.error('Error persisting to memory API:', error)
throw error
}
}
/**
* Atomically append a message to memory
*/
private async atomicAppendToMemory(
workflowId: string,
key: string,
message: Message
): Promise<void> {
try {
const isBrowser = typeof window !== 'undefined'
if (!isBrowser) {
await this.atomicAppendToMemoryDirect(workflowId, key, message)
} else {
const headers = await buildAuthHeaders()
const url = buildAPIUrl('/api/memory')
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: stringifyJSON({
workflowId,
key,
data: message,
}),
})
if (!response.ok) {
throw new Error(`Failed to append memory: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to append memory')
}
}
} catch (error) {
logger.error('Error appending to memory:', error)
throw error
}
}
/**
* Direct database atomic append for server-side
* Uses PostgreSQL JSONB concatenation operator for atomic operations
*/
private async atomicAppendToMemoryDirect(
workflowId: string,
key: string,
message: Message
): Promise<void> {
try {
const { db } = await import('@sim/db')
const { memory } = await import('@sim/db/schema')
const { sql } = await import('drizzle-orm')
const { randomUUID } = await import('node:crypto')
const now = new Date()
const id = randomUUID()
await db
.insert(memory)
.values({
id,
workflowId,
key,
data: [message],
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [memory.workflowId, memory.key],
set: {
data: sql`${memory.data} || ${JSON.stringify([message])}::jsonb`,
updatedAt: now,
},
})
logger.debug('Atomically appended message to memory', {
workflowId,
key,
})
} catch (error) {
logger.error('Error in atomic append to memory database:', error)
throw error
}
}
/**
* Direct database access for server-side persistence
* Uses UPSERT to handle race conditions atomically
*/
private async persistToMemoryDirect(
workflowId: string,
key: string,
messages: Message[]
): Promise<void> {
try {
const { db } = await import('@sim/db')
const { memory } = await import('@sim/db/schema')
const { randomUUID } = await import('node:crypto')
const now = new Date()
const id = randomUUID()
await db
.insert(memory)
.values({
id,
workflowId,
key,
data: messages,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [memory.workflowId, memory.key],
set: {
data: messages,
updatedAt: now,
},
})
} catch (error) {
logger.error('Error persisting to memory database:', error)
throw error
}
}
/**
* Validate inputs to prevent malicious data or performance issues
*/
private validateInputs(conversationId?: string, content?: string): void {
if (conversationId) {
if (conversationId.length > 255) {
throw new Error('Conversation ID too long (max 255 characters)')
}
if (!/^[a-zA-Z0-9_\-:.@]+$/.test(conversationId)) {
logger.warn('Conversation ID contains special characters', { conversationId })
}
}
if (content) {
const contentSize = Buffer.byteLength(content, 'utf8')
const MAX_CONTENT_SIZE = 100 * 1024 // 100KB
if (contentSize > MAX_CONTENT_SIZE) {
throw new Error(`Message content too large (${contentSize} bytes, max ${MAX_CONTENT_SIZE})`)
}
}
}
}
export const memoryService = new Memory()

View File

@@ -2,9 +2,18 @@ export interface AgentInputs {
model?: string
responseFormat?: string | object
tools?: ToolInput[]
// Legacy inputs (backward compatible)
systemPrompt?: string
userPrompt?: string | object
memories?: any
memories?: any // Legacy memory block output
// New message array input (from messages-input subblock)
messages?: Message[]
// Memory configuration
memoryType?: 'none' | 'conversation' | 'sliding_window' | 'sliding_window_tokens'
conversationId?: string // Required for all non-none memory types
slidingWindowSize?: string // For message-based sliding window
slidingWindowTokens?: string // For token-based sliding window
// LLM parameters
temperature?: number
maxTokens?: number
apiKey?: string

View File

@@ -1263,4 +1263,316 @@ describe('Database Helpers', () => {
expect(loadedState?.blocks['block-1'].advancedMode).toBe(true)
})
})
describe('migrateAgentBlocksToMessagesFormat', () => {
it('should migrate agent block with both systemPrompt and userPrompt', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
name: 'Test Agent',
position: { x: 0, y: 0 },
subBlocks: {
systemPrompt: {
id: 'systemPrompt',
type: 'textarea',
value: 'You are a helpful assistant',
},
userPrompt: {
id: 'userPrompt',
type: 'textarea',
value: 'Hello world',
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
expect(migrated['agent-1'].subBlocks.messages).toBeDefined()
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello world' },
])
// Old format should be preserved
expect(migrated['agent-1'].subBlocks.systemPrompt).toBeDefined()
expect(migrated['agent-1'].subBlocks.userPrompt).toBeDefined()
})
it('should migrate agent block with only systemPrompt', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
systemPrompt: {
id: 'systemPrompt',
type: 'textarea',
value: 'You are helpful',
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([
{ role: 'system', content: 'You are helpful' },
])
})
it('should migrate agent block with only userPrompt', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
userPrompt: {
id: 'userPrompt',
type: 'textarea',
value: 'Hello',
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([
{ role: 'user', content: 'Hello' },
])
})
it('should handle userPrompt as object with input field', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
userPrompt: {
id: 'userPrompt',
type: 'textarea',
value: { input: 'Hello from object' },
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([
{ role: 'user', content: 'Hello from object' },
])
})
it('should stringify userPrompt object without input field', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
userPrompt: {
id: 'userPrompt',
type: 'textarea',
value: { foo: 'bar', baz: 123 },
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([
{ role: 'user', content: '{"foo":"bar","baz":123}' },
])
})
it('should not migrate if messages array already exists', () => {
const existingMessages = [{ role: 'user', content: 'Existing message' }]
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
systemPrompt: {
id: 'systemPrompt',
type: 'textarea',
value: 'Old system',
},
userPrompt: {
id: 'userPrompt',
type: 'textarea',
value: 'Old user',
},
messages: {
id: 'messages',
type: 'messages-input',
value: existingMessages,
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
// Should not change existing messages
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual(existingMessages)
})
it('should not migrate if no old format prompts exist', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
model: {
id: 'model',
type: 'select',
value: 'gpt-4o',
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
// Should not add messages if no old format
expect(migrated['agent-1'].subBlocks.messages).toBeUndefined()
})
it('should handle non-agent blocks without modification', () => {
const blocks = {
'api-1': {
id: 'api-1',
type: 'api',
subBlocks: {
url: {
id: 'url',
type: 'input',
value: 'https://example.com',
},
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
// Non-agent block should remain unchanged
expect(migrated['api-1']).toEqual(blocks['api-1'])
expect(migrated['api-1'].subBlocks.messages).toBeUndefined()
})
it('should handle multiple blocks with mixed types', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System 1' },
},
outputs: {},
} as any,
'api-1': {
id: 'api-1',
type: 'api',
subBlocks: {},
outputs: {},
} as any,
'agent-2': {
id: 'agent-2',
type: 'agent',
subBlocks: {
userPrompt: { id: 'userPrompt', type: 'textarea', value: 'User 2' },
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
// First agent should be migrated
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([
{ role: 'system', content: 'System 1' },
])
// API block unchanged
expect(migrated['api-1']).toEqual(blocks['api-1'])
// Second agent should be migrated
expect(migrated['agent-2'].subBlocks.messages?.value).toEqual([
{ role: 'user', content: 'User 2' },
])
})
it('should handle empty string prompts by not migrating', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
systemPrompt: { id: 'systemPrompt', type: 'textarea', value: '' },
userPrompt: { id: 'userPrompt', type: 'textarea', value: '' },
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
// Empty strings are falsy, so migration should not occur
expect(migrated['agent-1'].subBlocks.messages).toBeUndefined()
})
it('should handle numeric prompt values by converting to string', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 123 },
},
outputs: {},
} as any,
}
const migrated = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
expect(migrated['agent-1'].subBlocks.messages?.value).toEqual([
{ role: 'system', content: '123' },
])
})
it('should be idempotent - running twice should not double migrate', () => {
const blocks = {
'agent-1': {
id: 'agent-1',
type: 'agent',
subBlocks: {
systemPrompt: { id: 'systemPrompt', type: 'textarea', value: 'System' },
},
outputs: {},
} as any,
}
// First migration
const migrated1 = dbHelpers.migrateAgentBlocksToMessagesFormat(blocks)
const messages1 = migrated1['agent-1'].subBlocks.messages?.value
// Second migration on already migrated blocks
const migrated2 = dbHelpers.migrateAgentBlocksToMessagesFormat(migrated1)
const messages2 = migrated2['agent-1'].subBlocks.messages?.value
// Should be identical - no double migration
expect(messages2).toEqual(messages1)
expect(messages2).toEqual([{ role: 'system', content: 'System' }])
})
})
})

View File

@@ -20,10 +20,8 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('WorkflowDBHelpers')
// Database types
export type WorkflowDeploymentVersion = InferSelectModel<typeof workflowDeploymentVersion>
// API response types (dates are serialized as strings)
export interface WorkflowDeploymentVersionResponse {
id: string
version: number
@@ -108,6 +106,77 @@ export async function loadDeployedWorkflowState(
}
}
/**
* Migrates agent blocks from old format (systemPrompt/userPrompt) to new format (messages array)
* This ensures backward compatibility for workflows created before the messages-input refactor.
*
* @param blocks - Record of block states to migrate
* @returns Migrated blocks with messages array format for agent blocks
*/
export function migrateAgentBlocksToMessagesFormat(
blocks: Record<string, BlockState>
): Record<string, BlockState> {
return Object.fromEntries(
Object.entries(blocks).map(([id, block]) => {
if (block.type === 'agent') {
const systemPrompt = block.subBlocks.systemPrompt?.value
const userPrompt = block.subBlocks.userPrompt?.value
const messages = block.subBlocks.messages?.value
// Only migrate if old format exists and new format doesn't
if ((systemPrompt || userPrompt) && !messages) {
const newMessages: Array<{ role: string; content: string }> = []
// Add system message first (industry standard)
if (systemPrompt) {
newMessages.push({
role: 'system',
content: typeof systemPrompt === 'string' ? systemPrompt : String(systemPrompt),
})
}
// Add user message
if (userPrompt) {
let userContent = userPrompt
// Handle object format (e.g., { input: "..." })
if (typeof userContent === 'object' && userContent !== null) {
if ('input' in userContent) {
userContent = (userContent as any).input
} else {
// If it's an object but doesn't have 'input', stringify it
userContent = JSON.stringify(userContent)
}
}
newMessages.push({
role: 'user',
content: String(userContent),
})
}
// Return block with migrated messages subBlock
return [
id,
{
...block,
subBlocks: {
...block.subBlocks,
messages: {
id: 'messages',
type: 'messages-input',
value: newMessages,
},
},
},
]
}
}
return [id, block]
})
)
}
/**
* Load workflow state from normalized tables
* Returns null if no data found (fallback to JSON blob)
@@ -157,6 +226,10 @@ export async function loadWorkflowFromNormalizedTables(
// Sanitize any invalid custom tools in agent blocks to prevent client crashes
const { blocks: sanitizedBlocks } = sanitizeAgentToolsInBlocks(blocksMap)
// Migrate old agent block format (systemPrompt/userPrompt) to new messages array format
// This ensures backward compatibility for workflows created before the messages-input refactor
const migratedBlocks = migrateAgentBlocksToMessagesFormat(sanitizedBlocks)
// Convert edges to the expected format
const edgesArray: Edge[] = edges.map((edge) => ({
id: edge.id,
@@ -198,9 +271,9 @@ export async function loadWorkflowFromNormalizedTables(
// Sync block.data with loop config to ensure all fields are present
// This allows switching between loop types without losing data
if (sanitizedBlocks[subflow.id]) {
const block = sanitizedBlocks[subflow.id]
sanitizedBlocks[subflow.id] = {
if (migratedBlocks[subflow.id]) {
const block = migratedBlocks[subflow.id]
migratedBlocks[subflow.id] = {
...block,
data: {
...block.data,
@@ -229,7 +302,7 @@ export async function loadWorkflowFromNormalizedTables(
})
return {
blocks: sanitizedBlocks,
blocks: migratedBlocks,
edges: edgesArray,
loops,
parallels,

View File

@@ -48,6 +48,7 @@ export interface ModelDefinition {
id: string
pricing: ModelPricing
capabilities: ModelCapabilities
contextWindow?: number // Maximum context window in tokens (may be undefined for dynamic providers)
}
export interface ProviderDefinition {
@@ -59,6 +60,8 @@ export interface ProviderDefinition {
modelPatterns?: RegExp[]
icon?: React.ComponentType<{ className?: string }>
capabilities?: ModelCapabilities
// Indicates whether reliable context window information is available for this provider's models
contextInformationAvailable?: boolean
}
/**
@@ -76,6 +79,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
contextInformationAvailable: false,
models: [],
},
openai: {
@@ -100,6 +104,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 128000,
},
{
id: 'gpt-5.1',
@@ -117,6 +122,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'gpt-5.1-mini',
@@ -134,6 +140,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'gpt-5.1-nano',
@@ -151,6 +158,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'gpt-5.1-codex',
@@ -168,6 +176,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'gpt-5',
@@ -185,6 +194,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'gpt-5-mini',
@@ -202,6 +212,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'gpt-5-nano',
@@ -219,6 +230,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'gpt-5-chat-latest',
@@ -229,6 +241,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {},
contextWindow: 400000,
},
{
id: 'o1',
@@ -239,6 +252,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-17',
},
capabilities: {},
contextWindow: 200000,
},
{
id: 'o3',
@@ -249,6 +263,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-17',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'o4-mini',
@@ -259,6 +274,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-17',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'gpt-4.1',
@@ -271,6 +287,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
{
id: 'gpt-4.1-nano',
@@ -283,6 +300,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
{
id: 'gpt-4.1-mini',
@@ -295,6 +313,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
],
},
@@ -320,6 +339,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 128000,
},
{
id: 'azure/gpt-5.1',
@@ -337,6 +357,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5.1-mini',
@@ -354,6 +375,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5.1-nano',
@@ -371,6 +393,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5.1-codex',
@@ -388,6 +411,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5',
@@ -405,6 +429,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5-mini',
@@ -422,6 +447,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5-nano',
@@ -439,6 +465,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
values: ['low', 'medium', 'high'],
},
},
contextWindow: 400000,
},
{
id: 'azure/gpt-5-chat-latest',
@@ -449,6 +476,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {},
contextWindow: 400000,
},
{
id: 'azure/o3',
@@ -459,6 +487,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-15',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'azure/o4-mini',
@@ -469,6 +498,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-15',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'azure/gpt-4.1',
@@ -479,6 +509,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-15',
},
capabilities: {},
contextWindow: 1000000,
},
{
id: 'azure/model-router',
@@ -489,6 +520,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-06-15',
},
capabilities: {},
contextWindow: 1000000,
},
],
},
@@ -514,6 +546,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 200000,
},
{
id: 'claude-sonnet-4-5',
@@ -526,6 +559,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 200000,
},
{
id: 'claude-sonnet-4-0',
@@ -538,6 +572,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 200000,
},
{
id: 'claude-opus-4-1',
@@ -550,6 +585,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 200000,
},
{
id: 'claude-opus-4-0',
@@ -562,6 +598,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 200000,
},
{
id: 'claude-3-7-sonnet-latest',
@@ -575,6 +612,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
temperature: { min: 0, max: 1 },
computerUse: true,
},
contextWindow: 200000,
},
{
id: 'claude-3-5-sonnet-latest',
@@ -588,6 +626,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
temperature: { min: 0, max: 1 },
computerUse: true,
},
contextWindow: 200000,
},
],
},
@@ -602,6 +641,19 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
icon: GeminiIcon,
models: [
{
id: 'gemini-3-pro-preview',
pricing: {
input: 2.0,
cachedInput: 1.0,
output: 12.0,
updatedAt: '2025-11-18',
},
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1000000,
},
{
id: 'gemini-2.5-pro',
pricing: {
@@ -613,6 +665,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1048576,
},
{
id: 'gemini-2.5-flash',
@@ -625,6 +678,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 1048576,
},
],
},
@@ -648,6 +702,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-03-21',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'deepseek-v3',
@@ -660,6 +715,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 2 },
},
contextWindow: 128000,
},
{
id: 'deepseek-r1',
@@ -670,6 +726,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-03-21',
},
capabilities: {},
contextWindow: 128000,
},
],
},
@@ -695,6 +752,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 256000,
},
{
id: 'grok-4-fast-reasoning',
@@ -707,6 +765,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 2000000,
},
{
id: 'grok-4-fast-non-reasoning',
@@ -719,6 +778,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 2000000,
},
{
id: 'grok-code-fast-1',
@@ -731,6 +791,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 256000,
},
{
id: 'grok-3-latest',
@@ -743,6 +804,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 131072,
},
{
id: 'grok-3-fast-latest',
@@ -755,6 +817,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 131072,
},
],
},
@@ -777,6 +840,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 32000,
},
{
id: 'cerebras/llama-3.1-70b',
@@ -786,6 +850,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'cerebras/llama-3.3-70b',
@@ -795,6 +860,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'cerebras/llama-4-scout-17b-16e-instruct',
@@ -804,6 +870,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 10000000,
},
],
},
@@ -826,6 +893,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/openai/gpt-oss-20b',
@@ -835,6 +903,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/llama-3.1-8b-instant',
@@ -844,6 +913,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/llama-3.3-70b-versatile',
@@ -853,6 +923,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/llama-4-scout-17b-instruct',
@@ -862,6 +933,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/llama-4-maverick-17b-instruct',
@@ -871,6 +943,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/meta-llama/llama-4-maverick-17b-128e-instruct',
@@ -880,6 +953,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/gemma2-9b-it',
@@ -889,6 +963,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 8192,
},
{
id: 'groq/deepseek-r1-distill-llama-70b',
@@ -898,6 +973,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 128000,
},
{
id: 'groq/moonshotai/kimi-k2-instruct',
@@ -907,6 +983,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
{
id: 'groq/meta-llama/llama-guard-4-12b',
@@ -916,6 +993,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-10-11',
},
capabilities: {},
contextWindow: 131072,
},
],
},
@@ -940,6 +1018,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'mistral-large-2411',
@@ -951,6 +1030,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'magistral-medium-latest',
@@ -962,6 +1042,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'magistral-medium-2509',
@@ -973,6 +1054,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'mistral-medium-latest',
@@ -984,6 +1066,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'mistral-medium-2508',
@@ -995,6 +1078,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'mistral-small-latest',
@@ -1006,6 +1090,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'mistral-small-2506',
@@ -1017,6 +1102,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'open-mistral-nemo',
@@ -1028,6 +1114,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'codestral-latest',
@@ -1039,6 +1126,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 256000,
},
{
id: 'codestral-2508',
@@ -1050,6 +1138,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 256000,
},
{
id: 'ministral-8b-latest',
@@ -1061,6 +1150,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'ministral-8b-2410',
@@ -1072,6 +1162,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
{
id: 'ministral-3b-latest',
@@ -1083,6 +1174,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
},
contextWindow: 128000,
},
],
},
@@ -1096,6 +1188,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
toolUsageControl: false, // Ollama does not support tool_choice parameter
},
contextInformationAvailable: false,
models: [], // Populated dynamically
},
}

View File

@@ -38,6 +38,63 @@ function shouldIncludeField(subBlockConfig: SubBlockConfig, isAdvancedMode: bool
return true
}
/**
* Helper function to migrate agent block params from old format to messages array
* Transforms systemPrompt/userPrompt into messages array format
* Only migrates if old format exists and new format doesn't (idempotent)
*/
function migrateAgentParamsToMessages(
params: Record<string, any>,
subBlocks: Record<string, any>,
blockId: string
): void {
// Only migrate if old format exists and new format doesn't
if ((params.systemPrompt || params.userPrompt) && !params.messages) {
logger.info('Migrating agent block from legacy format to messages array', {
blockId,
hasSystemPrompt: !!params.systemPrompt,
hasUserPrompt: !!params.userPrompt,
})
const messages: any[] = []
// Add system message first (industry standard)
if (params.systemPrompt) {
messages.push({
role: 'system',
content: params.systemPrompt,
})
}
// Add user message
if (params.userPrompt) {
let userContent = params.userPrompt
// Handle object format (e.g., { input: "..." })
if (typeof userContent === 'object' && userContent !== null) {
if ('input' in userContent) {
userContent = userContent.input
} else {
// If it's an object but doesn't have 'input', stringify it
userContent = JSON.stringify(userContent)
}
}
messages.push({
role: 'user',
content: String(userContent),
})
}
// Set the migrated messages in subBlocks
subBlocks.messages = {
id: 'messages',
type: 'messages-input',
value: messages,
}
}
}
export class Serializer {
serializeWorkflow(
blocks: Record<string, BlockState>,
@@ -679,6 +736,11 @@ export class Serializer {
}
})
// Migration logic for agent blocks: Transform old systemPrompt/userPrompt to messages array
if (blockType === 'agent') {
migrateAgentParamsToMessages(serializedBlock.config.params, subBlocks, serializedBlock.id)
}
return {
id: serializedBlock.id,
type: blockType,

View File

@@ -1,3 +1,4 @@
import { buildMemoryKey } from '@/tools/memory/helpers'
import type { MemoryResponse } from '@/tools/memory/types'
import type { ToolConfig } from '@/tools/types'
@@ -8,11 +9,11 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
version: '1.0.0',
params: {
id: {
conversationId: {
type: 'string',
required: true,
description:
'Identifier for the memory. If a memory with this ID already exists, the new data will be appended to it.',
'Conversation identifier (e.g., user-123, session-abc). If a memory with this conversationId already exists for this block, the new message will be appended to it.',
},
role: {
type: 'string',
@@ -24,6 +25,12 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
required: true,
description: 'Content for agent memory',
},
blockId: {
type: 'string',
required: false,
description:
'Optional block ID. If not provided, uses the current block ID from execution context.',
},
},
request: {
@@ -33,10 +40,9 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
'Content-Type': 'application/json',
}),
body: (params) => {
// Get workflowId from context (set by workflow execution)
const workflowId = params._context?.workflowId
const contextBlockId = params._context?.blockId
// Prepare error response instead of throwing error
if (!workflowId) {
return {
_errorResponse: {
@@ -51,13 +57,36 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
}
}
const body: Record<string, any> = {
key: params.id,
type: 'agent', // Always agent type
workflowId,
const blockId = params.blockId || contextBlockId
if (!blockId) {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message:
'blockId is required. Either provide it as a parameter or ensure it is available in execution context.',
},
},
},
}
}
if (!params.conversationId || params.conversationId.trim() === '') {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message: 'conversationId is required',
},
},
},
}
}
// Validate and set data
if (!params.role || !params.content) {
return {
_errorResponse: {
@@ -72,9 +101,15 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
}
}
body.data = {
role: params.role,
content: params.content,
const key = buildMemoryKey(params.conversationId, blockId)
const body: Record<string, any> = {
key,
workflowId,
data: {
role: params.role,
content: params.content,
},
}
return body
@@ -85,7 +120,6 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
const result = await response.json()
const data = result.data || result
// For agent memories, return the full array of message objects
const memories = Array.isArray(data.data) ? data.data : [data.data]
return {

View File

@@ -4,20 +4,33 @@ import type { ToolConfig } from '@/tools/types'
export const memoryDeleteTool: ToolConfig<any, MemoryResponse> = {
id: 'memory_delete',
name: 'Delete Memory',
description: 'Delete a specific memory by its ID',
description:
'Delete memories by conversationId, blockId, blockName, or a combination. Supports bulk deletion.',
version: '1.0.0',
params: {
id: {
conversationId: {
type: 'string',
required: true,
description: 'Identifier for the memory to delete',
required: false,
description:
'Conversation identifier (e.g., user-123, session-abc). If provided alone, deletes all memories for this conversation across all blocks.',
},
blockId: {
type: 'string',
required: false,
description:
'Block identifier. If provided alone, deletes all memories for this block across all conversations. If provided with conversationId, deletes memories for that specific conversation in this block.',
},
blockName: {
type: 'string',
required: false,
description:
'Block name. Alternative to blockId. If provided alone, deletes all memories for blocks with this name. If provided with conversationId, deletes memories for that conversation in blocks with this name.',
},
},
request: {
url: (params): any => {
// Get workflowId from context (set by workflow execution)
const workflowId = params._context?.workflowId
if (!workflowId) {
@@ -34,21 +47,49 @@ export const memoryDeleteTool: ToolConfig<any, MemoryResponse> = {
}
}
// Append workflowId as query parameter
return `/api/memory/${encodeURIComponent(params.id)}?workflowId=${encodeURIComponent(workflowId)}`
if (!params.conversationId && !params.blockId && !params.blockName) {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message: 'At least one of conversationId, blockId, or blockName must be provided',
},
},
},
}
}
const url = new URL('/api/memory', 'http://dummy')
url.searchParams.set('workflowId', workflowId)
if (params.conversationId) {
url.searchParams.set('conversationId', params.conversationId)
}
if (params.blockId) {
url.searchParams.set('blockId', params.blockId)
}
if (params.blockName) {
url.searchParams.set('blockName', params.blockName)
}
return url.pathname + url.search
},
method: 'DELETE',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response): Promise<MemoryResponse> => {
const result = await response.json()
const data = result.data || result
return {
success: true,
success: result.success !== false,
output: {
message: 'Memory deleted successfully.',
message: data.message || 'Memories deleted successfully',
},
}
},

View File

@@ -1,23 +1,37 @@
import { buildMemoryKey } from '@/tools/memory/helpers'
import type { MemoryResponse } from '@/tools/memory/types'
import type { ToolConfig } from '@/tools/types'
export const memoryGetTool: ToolConfig<any, MemoryResponse> = {
id: 'memory_get',
name: 'Get Memory',
description: 'Retrieve a specific memory by its ID',
description:
'Retrieve memory by conversationId, blockId, blockName, or a combination. Returns all matching memories.',
version: '1.0.0',
params: {
id: {
conversationId: {
type: 'string',
required: true,
description: 'Identifier for the memory to retrieve',
required: false,
description:
'Conversation identifier (e.g., user-123, session-abc). If provided alone, returns all memories for this conversation across all blocks.',
},
blockId: {
type: 'string',
required: false,
description:
'Block identifier. If provided alone, returns all memories for this block across all conversations. If provided with conversationId, returns memories for that specific conversation in this block.',
},
blockName: {
type: 'string',
required: false,
description:
'Block name. Alternative to blockId. If provided alone, returns all memories for blocks with this name. If provided with conversationId, returns memories for that conversation in blocks with this name.',
},
},
request: {
url: (params): any => {
// Get workflowId from context (set by workflow execution)
const workflowId = params._context?.workflowId
if (!workflowId) {
@@ -34,8 +48,41 @@ export const memoryGetTool: ToolConfig<any, MemoryResponse> = {
}
}
// Append workflowId as query parameter
return `/api/memory/${encodeURIComponent(params.id)}?workflowId=${encodeURIComponent(workflowId)}`
if (!params.conversationId && !params.blockId && !params.blockName) {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message: 'At least one of conversationId, blockId, or blockName must be provided',
},
},
},
}
}
let query = ''
if (params.conversationId && params.blockId) {
query = buildMemoryKey(params.conversationId, params.blockId)
} else if (params.conversationId) {
query = `${params.conversationId}:`
} else if (params.blockId) {
query = `:${params.blockId}`
}
const url = new URL('/api/memory', 'http://dummy')
url.searchParams.set('workflowId', workflowId)
if (query) {
url.searchParams.set('query', query)
}
if (params.blockName) {
url.searchParams.set('blockName', params.blockName)
}
url.searchParams.set('limit', '1000')
return url.pathname + url.search
},
method: 'GET',
headers: () => ({
@@ -45,20 +92,34 @@ export const memoryGetTool: ToolConfig<any, MemoryResponse> = {
transformResponse: async (response): Promise<MemoryResponse> => {
const result = await response.json()
const data = result.data || result
const memories = result.data?.memories || []
if (!Array.isArray(memories) || memories.length === 0) {
return {
success: true,
output: {
memories: [],
message: 'No memories found',
},
}
}
return {
success: true,
output: {
memories: data.data,
message: 'Memory retrieved successfully',
memories,
message: `Found ${memories.length} memories`,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the memory was retrieved successfully' },
memories: { type: 'array', description: 'Array of memory data for the requested ID' },
memories: {
type: 'array',
description:
'Array of memory objects with conversationId, blockId, blockName, and data fields',
},
message: { type: 'string', description: 'Success or error message' },
error: { type: 'string', description: 'Error message if operation failed' },
},

View File

@@ -11,7 +11,6 @@ export const memoryGetAllTool: ToolConfig<any, MemoryResponse> = {
request: {
url: (params): any => {
// Get workflowId from context (set by workflow execution)
const workflowId = params._context?.workflowId
if (!workflowId) {
@@ -28,7 +27,6 @@ export const memoryGetAllTool: ToolConfig<any, MemoryResponse> = {
}
}
// Append workflowId as query parameter
return `/api/memory?workflowId=${encodeURIComponent(workflowId)}`
},
method: 'GET',
@@ -40,22 +38,24 @@ export const memoryGetAllTool: ToolConfig<any, MemoryResponse> = {
transformResponse: async (response): Promise<MemoryResponse> => {
const result = await response.json()
// Extract memories from the response
const data = result.data || result
const rawMemories = data.memories || data || []
const memories = data.memories || data || []
// Transform memories to return them with their keys and types for better context
const memories = rawMemories.map((memory: any) => ({
key: memory.key,
type: memory.type,
data: memory.data,
}))
if (!Array.isArray(memories) || memories.length === 0) {
return {
success: true,
output: {
memories: [],
message: 'No memories found',
},
}
}
return {
success: true,
output: {
memories,
message: 'Memories retrieved successfully',
message: `Found ${memories.length} memories`,
},
}
},
@@ -64,7 +64,8 @@ export const memoryGetAllTool: ToolConfig<any, MemoryResponse> = {
success: { type: 'boolean', description: 'Whether all memories were retrieved successfully' },
memories: {
type: 'array',
description: 'Array of all memory objects with keys, types, and data',
description:
'Array of all memory objects with key, conversationId, blockId, blockName, and data fields',
},
message: { type: 'string', description: 'Success or error message' },
error: { type: 'string', description: 'Error message if operation failed' },

View File

@@ -0,0 +1,27 @@
/**
* Parse memory key into conversationId and blockId
* Key format: conversationId:blockId
* @param key The memory key to parse
* @returns Object with conversationId and blockId, or null if invalid
*/
export function parseMemoryKey(key: string): { conversationId: string; blockId: string } | null {
const parts = key.split(':')
if (parts.length !== 2) {
return null
}
return {
conversationId: parts[0],
blockId: parts[1],
}
}
/**
* Build memory key from conversationId and blockId
* @param conversationId The conversation ID
* @param blockId The block ID
* @returns The memory key in format conversationId:blockId
*/
export function buildMemoryKey(conversationId: string, blockId: string): string {
return `${conversationId}:${blockId}`
}

View File

@@ -15,7 +15,9 @@ export interface AgentMemoryData {
export interface MemoryRecord {
id: string
key: string
type: 'agent'
conversationId: string
blockId: string
blockName: string
data: AgentMemoryData[]
createdAt: string
updatedAt: string

View File

@@ -0,0 +1,2 @@
ALTER TABLE "memory" ALTER COLUMN "data" SET DATA TYPE jsonb;--> statement-breakpoint
ALTER TABLE "memory" DROP COLUMN "type";

File diff suppressed because it is too large Load Diff

View File

@@ -757,6 +757,13 @@
"when": 1762572820066,
"tag": "0108_cuddly_scream",
"breakpoints": true
},
{
"idx": 109,
"version": "7",
"when": 1763503885555,
"tag": "0109_bumpy_earthquake",
"breakpoints": true
}
]
}

View File

@@ -916,9 +916,8 @@ export const memory = pgTable(
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
key: text('key').notNull(), // Identifier for the memory within its context
type: text('type').notNull(), // 'agent' or 'raw'
data: json('data').notNull(), // Stores either agent message data or raw data
key: text('key').notNull(), // Conversation ID provided by user with format: conversationId:blockId
data: jsonb('data').notNull(), // Stores agent messages as array of {role, content} objects
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
deletedAt: timestamp('deleted_at'),