mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
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:
@@ -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:
|
||||
|
||||
@@ -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,10 +315,6 @@ 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
|
||||
@@ -281,27 +331,52 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
{ 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 }
|
||||
)
|
||||
|
||||
@@ -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,106 +304,102 @@ 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: 'Agent memory requires role and content',
|
||||
message: 'Workflow not found',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!['user', 'assistant', 'system'].includes(data.role)) {
|
||||
logger.warn(`[${requestId}] Invalid agent role: ${data.role}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Agent role must be user, assistant, or system',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
const { workspacePermission, isOwner } = accessContext
|
||||
|
||||
// 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 hasWritePermission =
|
||||
isOwner || workspacePermission === 'write' || workspacePermission === 'admin'
|
||||
|
||||
let statusCode = 201 // Default status code for new memory
|
||||
|
||||
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) {
|
||||
if (!hasWritePermission) {
|
||||
logger.warn(
|
||||
`[${requestId}] Memory type mismatch: existing=${existingMemory[0].type}, new=${type}`
|
||||
`[${requestId}] User ${authResult.userId} denied write access to workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: `Cannot append memory of type '${type}' to existing memory of type '${existingMemory[0].type}'`,
|
||||
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: 'Memory requires messages with role and content',
|
||||
},
|
||||
},
|
||||
{ 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 (!['user', 'assistant', 'system'].includes(msg.role)) {
|
||||
logger.warn(`[${requestId}] Invalid message role: ${msg.role}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Message role must be user, assistant, or system',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// If existing data is a single message object, convert to array
|
||||
else {
|
||||
updatedData = [existingData, newMessage]
|
||||
}
|
||||
|
||||
// Update the existing memory with appended data
|
||||
const initialData = Array.isArray(data) ? data : [data]
|
||||
const now = new Date()
|
||||
const id = `mem_${crypto.randomUUID().replace(/-/g, '')}`
|
||||
|
||||
const { sql } = await import('drizzle-orm')
|
||||
|
||||
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, '')}`,
|
||||
.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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Label } from '@/components/ui'
|
||||
import { Label } from '@/components/emcn'
|
||||
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
|
||||
|
||||
interface ExistingChat {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
<>
|
||||
<UIButton
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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' />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,8 +289,32 @@ const PopoverContent = React.forwardRef<
|
||||
style?.maxWidth !== undefined ||
|
||||
style?.width !== undefined
|
||||
|
||||
return (
|
||||
<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}
|
||||
@@ -291,6 +323,7 @@ const PopoverContent = React.forwardRef<
|
||||
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)]',
|
||||
@@ -304,18 +337,19 @@ const PopoverContent = React.forwardRef<
|
||||
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',
|
||||
minWidth !== undefined ? `${minWidth}px` : hasUserWidthConstraint ? undefined : '160px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
|
||||
if (disablePortal) {
|
||||
return content
|
||||
}
|
||||
|
||||
return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,18 +466,32 @@ 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 })
|
||||
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 {
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.splice(0, 0, { role: 'system', content })
|
||||
}
|
||||
}
|
||||
|
||||
private addUserPrompt(messages: Message[], userPrompt: any) {
|
||||
@@ -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,
|
||||
|
||||
277
apps/sim/executor/handlers/agent/memory.test.ts
Normal file
277
apps/sim/executor/handlers/agent/memory.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
663
apps/sim/executor/handlers/agent/memory.ts
Normal file
663
apps/sim/executor/handlers/agent/memory.ts
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
27
apps/sim/tools/memory/helpers.ts
Normal file
27
apps/sim/tools/memory/helpers.ts
Normal 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}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
2
packages/db/migrations/0109_bumpy_earthquake.sql
Normal file
2
packages/db/migrations/0109_bumpy_earthquake.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "memory" ALTER COLUMN "data" SET DATA TYPE jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "memory" DROP COLUMN "type";
|
||||
7672
packages/db/migrations/meta/0109_snapshot.json
Normal file
7672
packages/db/migrations/meta/0109_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -757,6 +757,13 @@
|
||||
"when": 1762572820066,
|
||||
"tag": "0108_cuddly_scream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 109,
|
||||
"version": "7",
|
||||
"when": 1763503885555,
|
||||
"tag": "0109_bumpy_earthquake",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user