mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
feat(deployed-chat): added file upload to workflow execute API, added to deployed chat, updated chat panel (#1588)
* feat(deployed-chat): updated chat panel UI, deployed chat and API can now accept files * added nested tag dropdown for files * added duplicate file validation to chat panel * update docs & SDKs * fixed build * rm extraneous comments * ack PR comments, cut multiple DB roundtrips for permissions & api key checks in api/workflows * allow read-only users to access deployment info, but not take actions * add downloadable file to logs for files passed in via API * protect files/serve route that is only used client-side --------- Co-authored-by: waleed <waleed>
This commit is contained in:
@@ -593,14 +593,91 @@ async function executeClientSideWorkflow() {
|
||||
});
|
||||
|
||||
console.log('Workflow result:', result);
|
||||
|
||||
|
||||
// Update UI with result
|
||||
document.getElementById('result')!.textContent =
|
||||
document.getElementById('result')!.textContent =
|
||||
JSON.stringify(result.output, null, 2);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
File objects are automatically detected and converted to base64 format. Include them in your input under the field name matching your workflow's API trigger input format.
|
||||
|
||||
The SDK converts File objects to this format:
|
||||
```typescript
|
||||
{
|
||||
type: 'file',
|
||||
data: 'data:mime/type;base64,base64data',
|
||||
name: 'filename',
|
||||
mime: 'mime/type'
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can manually provide files using the URL format:
|
||||
```typescript
|
||||
{
|
||||
type: 'url',
|
||||
data: 'https://example.com/file.pdf',
|
||||
name: 'file.pdf',
|
||||
mime: 'application/pdf'
|
||||
}
|
||||
```
|
||||
|
||||
<Tabs items={['Browser', 'Node.js']}>
|
||||
<Tab value="Browser">
|
||||
```typescript
|
||||
import { SimStudioClient } from 'simstudio-ts-sdk';
|
||||
|
||||
const client = new SimStudioClient({
|
||||
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
|
||||
});
|
||||
|
||||
// From file input
|
||||
async function handleFileUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
|
||||
// Include files under the field name from your API trigger's input format
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: {
|
||||
documents: files, // Must match your workflow's "files" field name
|
||||
instructions: 'Analyze these documents'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Result:', result);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="Node.js">
|
||||
```typescript
|
||||
import { SimStudioClient } from 'simstudio-ts-sdk';
|
||||
import fs from 'fs';
|
||||
|
||||
const client = new SimStudioClient({
|
||||
apiKey: process.env.SIM_API_KEY!
|
||||
});
|
||||
|
||||
// Read file and create File object
|
||||
const fileBuffer = fs.readFileSync('./document.pdf');
|
||||
const file = new File([fileBuffer], 'document.pdf', {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
|
||||
// Include files under the field name from your API trigger's input format
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: {
|
||||
documents: [file], // Must match your workflow's "files" field name
|
||||
query: 'Summarize this document'
|
||||
}
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
// Attach to button click
|
||||
document.getElementById('executeBtn')?.addEventListener('click', executeClientSideWorkflow);
|
||||
|
||||
@@ -22,9 +22,17 @@ The API trigger exposes your workflow as a secure HTTP endpoint. Send JSON data
|
||||
/>
|
||||
</div>
|
||||
|
||||
Add an **Input Format** field for each parameter. Runtime output keys mirror the schema and are also available under `<api.input>`.
|
||||
Add an **Input Format** field for each parameter. Supported types:
|
||||
|
||||
Manual runs in the editor use the `value` column so you can test without sending a request. During execution the resolver populates both `<api.userId>` and `<api.input.userId>`.
|
||||
- **string** - Text values
|
||||
- **number** - Numeric values
|
||||
- **boolean** - True/false values
|
||||
- **json** - JSON objects
|
||||
- **files** - File uploads (access via `<api.fieldName[0].url>`, `<api.fieldName[0].name>`, etc.)
|
||||
|
||||
Runtime output keys mirror the schema and are available under `<api.input>`.
|
||||
|
||||
Manual runs in the editor use the `value` column so you can test without sending a request. During execution the resolver populates both `<api.fieldName>` and `<api.input.fieldName>`.
|
||||
|
||||
## Request Example
|
||||
|
||||
@@ -123,6 +131,53 @@ data: {"blockId":"agent1-uuid","chunk":" complete"}
|
||||
| `<api.field>` | Field defined in the Input Format |
|
||||
| `<api.input>` | Entire structured request body |
|
||||
|
||||
### File Upload Format
|
||||
|
||||
The API accepts files in two formats:
|
||||
|
||||
**1. Base64-encoded files** (recommended for SDKs):
|
||||
```json
|
||||
{
|
||||
"documents": [{
|
||||
"type": "file",
|
||||
"data": "data:application/pdf;base64,JVBERi0xLjQK...",
|
||||
"name": "document.pdf",
|
||||
"mime": "application/pdf"
|
||||
}]
|
||||
}
|
||||
```
|
||||
- Maximum file size: 20MB per file
|
||||
- Files are uploaded to cloud storage and converted to UserFile objects with all properties
|
||||
|
||||
**2. Direct URL references**:
|
||||
```json
|
||||
{
|
||||
"documents": [{
|
||||
"type": "url",
|
||||
"data": "https://example.com/document.pdf",
|
||||
"name": "document.pdf",
|
||||
"mime": "application/pdf"
|
||||
}]
|
||||
}
|
||||
```
|
||||
- File is not uploaded, URL is passed through directly
|
||||
- Useful for referencing existing files
|
||||
|
||||
### File Properties
|
||||
|
||||
For files, access all properties:
|
||||
|
||||
| Property | Description | Type |
|
||||
|----------|-------------|------|
|
||||
| `<api.fieldName[0].url>` | Signed download URL | string |
|
||||
| `<api.fieldName[0].name>` | Original filename | string |
|
||||
| `<api.fieldName[0].size>` | File size in bytes | number |
|
||||
| `<api.fieldName[0].type>` | MIME type | string |
|
||||
| `<api.fieldName[0].uploadedAt>` | Upload timestamp (ISO 8601) | string |
|
||||
| `<api.fieldName[0].expiresAt>` | URL expiry timestamp (ISO 8601) | string |
|
||||
|
||||
For URL-referenced files, the same properties are available except `uploadedAt` and `expiresAt` since the file is not uploaded to our storage.
|
||||
|
||||
If no Input Format is defined, the executor exposes the raw JSON at `<api.input>` only.
|
||||
|
||||
<Callout type="warning">
|
||||
|
||||
@@ -24,13 +24,24 @@ The Chat trigger creates a conversational interface for your workflow. Deploy yo
|
||||
|
||||
The trigger writes three fields that downstream blocks can reference:
|
||||
|
||||
| Reference | Description |
|
||||
|-----------|-------------|
|
||||
| `<chat.input>` | Latest user message |
|
||||
| `<chat.conversationId>` | Conversation thread ID |
|
||||
| `<chat.files>` | Optional uploaded files |
|
||||
| Reference | Description | Type |
|
||||
|-----------|-------------|------|
|
||||
| `<chat.input>` | Latest user message | string |
|
||||
| `<chat.conversationId>` | Conversation thread ID | string |
|
||||
| `<chat.files>` | Optional uploaded files | files array |
|
||||
|
||||
Files include `name`, `mimeType`, and a signed download `url`.
|
||||
### File Properties
|
||||
|
||||
Access individual file properties using array indexing:
|
||||
|
||||
| Property | Description | Type |
|
||||
|----------|-------------|------|
|
||||
| `<chat.files[0].url>` | Signed download URL | string |
|
||||
| `<chat.files[0].name>` | Original filename | string |
|
||||
| `<chat.files[0].size>` | File size in bytes | number |
|
||||
| `<chat.files[0].type>` | MIME type | string |
|
||||
| `<chat.files[0].uploadedAt>` | Upload timestamp (ISO 8601) | string |
|
||||
| `<chat.files[0].expiresAt>` | URL expiry timestamp (ISO 8601) | string |
|
||||
|
||||
## Usage Notes
|
||||
|
||||
|
||||
@@ -1116,12 +1116,20 @@ export function createMockDatabase(options: MockDatabaseOptions = {}) {
|
||||
|
||||
const createUpdateChain = () => ({
|
||||
set: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockImplementation(() => {
|
||||
if (updateOptions.throwError) {
|
||||
return Promise.reject(createDbError('update', updateOptions.errorMessage))
|
||||
}
|
||||
return Promise.resolve(updateOptions.results)
|
||||
}),
|
||||
where: vi.fn().mockImplementation(() => ({
|
||||
returning: vi.fn().mockImplementation(() => {
|
||||
if (updateOptions.throwError) {
|
||||
return Promise.reject(createDbError('update', updateOptions.errorMessage))
|
||||
}
|
||||
return Promise.resolve(updateOptions.results)
|
||||
}),
|
||||
then: vi.fn().mockImplementation((resolve) => {
|
||||
if (updateOptions.throwError) {
|
||||
return Promise.reject(createDbError('update', updateOptions.errorMessage))
|
||||
}
|
||||
return Promise.resolve(updateOptions.results).then(resolve)
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import {
|
||||
addCorsHeaders,
|
||||
processChatFiles,
|
||||
setChatAuthCookie,
|
||||
validateAuthToken,
|
||||
validateChatAuth,
|
||||
@@ -75,7 +76,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
// Use the already parsed body
|
||||
const { input, password, email, conversationId } = parsedBody
|
||||
const { input, password, email, conversationId, files } = parsedBody
|
||||
|
||||
// If this is an authentication request (has password or email but no input),
|
||||
// set auth cookie and return success
|
||||
@@ -88,8 +89,8 @@ export async function POST(
|
||||
return response
|
||||
}
|
||||
|
||||
// For chat messages, create regular response
|
||||
if (!input) {
|
||||
// For chat messages, create regular response (allow empty input if files are present)
|
||||
if (!input && (!files || files.length === 0)) {
|
||||
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
|
||||
}
|
||||
|
||||
@@ -108,7 +109,6 @@ export async function POST(
|
||||
}
|
||||
|
||||
try {
|
||||
// Transform outputConfigs to selectedOutputs format (blockId_attribute format)
|
||||
const selectedOutputs: string[] = []
|
||||
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
|
||||
for (const config of deployment.outputConfigs) {
|
||||
@@ -123,11 +123,30 @@ export async function POST(
|
||||
const { SSE_HEADERS } = await import('@/lib/utils')
|
||||
const { createFilteredResult } = await import('@/app/api/workflows/[id]/execute/route')
|
||||
|
||||
const workflowInput: any = { input, conversationId }
|
||||
if (files && Array.isArray(files) && files.length > 0) {
|
||||
logger.debug(`[${requestId}] Processing ${files.length} attached files`)
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
const executionContext = {
|
||||
workspaceId: deployment.userId,
|
||||
workflowId: deployment.workflowId,
|
||||
executionId,
|
||||
}
|
||||
|
||||
const uploadedFiles = await processChatFiles(files, executionContext, requestId)
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
workflowInput.files = uploadedFiles
|
||||
logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`)
|
||||
}
|
||||
}
|
||||
|
||||
const stream = await createStreamingResponse({
|
||||
requestId,
|
||||
workflow: { id: deployment.workflowId, userId: deployment.userId, isDeployed: true },
|
||||
input: { input, conversationId }, // Format for chat_trigger
|
||||
executingUserId: deployment.userId, // Use workflow owner's ID for chat deployments
|
||||
input: workflowInput,
|
||||
executingUserId: deployment.userId,
|
||||
streamConfig: {
|
||||
selectedOutputs,
|
||||
isSecureMode: true,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { uploadExecutionFile } from '@/lib/workflows/execution-file-storage'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
|
||||
const logger = createLogger('ChatAuthUtils')
|
||||
|
||||
@@ -263,3 +265,61 @@ export async function validateChatAuth(
|
||||
// Unknown auth type
|
||||
return { authorized: false, error: 'Unsupported authentication type' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and upload chat files to execution storage
|
||||
* Handles both base64 dataUrl format and direct URL pass-through
|
||||
*/
|
||||
export async function processChatFiles(
|
||||
files: Array<{ dataUrl?: string; url?: string; name: string; type: string }>,
|
||||
executionContext: { workspaceId: string; workflowId: string; executionId: string },
|
||||
requestId: string
|
||||
): Promise<UserFile[]> {
|
||||
const uploadedFiles: UserFile[] = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.dataUrl) {
|
||||
const dataUrlPrefix = 'data:'
|
||||
const base64Prefix = ';base64,'
|
||||
|
||||
if (!file.dataUrl.startsWith(dataUrlPrefix)) {
|
||||
logger.warn(`[${requestId}] Invalid dataUrl format for file: ${file.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const base64Index = file.dataUrl.indexOf(base64Prefix)
|
||||
if (base64Index === -1) {
|
||||
logger.warn(
|
||||
`[${requestId}] Invalid dataUrl format (no base64 marker) for file: ${file.name}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const mimeType = file.dataUrl.substring(dataUrlPrefix.length, base64Index)
|
||||
const base64Data = file.dataUrl.substring(base64Index + base64Prefix.length)
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
logger.debug(`[${requestId}] Uploading file to S3: ${file.name} (${buffer.length} bytes)`)
|
||||
|
||||
const userFile = await uploadExecutionFile(
|
||||
executionContext,
|
||||
buffer,
|
||||
file.name,
|
||||
mimeType || file.type
|
||||
)
|
||||
|
||||
uploadedFiles.push(userFile)
|
||||
logger.debug(`[${requestId}] Successfully uploaded ${file.name} with URL: ${userFile.url}`)
|
||||
} else if (file.url) {
|
||||
uploadedFiles.push(file as UserFile)
|
||||
logger.debug(`[${requestId}] Using existing URL for file: ${file.name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to process file ${file.name}:`, error)
|
||||
throw new Error(`Failed to upload file: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedFiles
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { downloadFile, getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
|
||||
import { S3_KB_CONFIG } from '@/lib/uploads/setup'
|
||||
import '@/lib/uploads/setup.server'
|
||||
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
createErrorResponse,
|
||||
createFileResponse,
|
||||
@@ -15,9 +16,6 @@ import {
|
||||
|
||||
const logger = createLogger('FilesServeAPI')
|
||||
|
||||
/**
|
||||
* Main API route handler for serving files
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
@@ -31,27 +29,30 @@ export async function GET(
|
||||
|
||||
logger.info('File serve request:', { path })
|
||||
|
||||
// Join the path segments to get the filename or cloud key
|
||||
const fullPath = path.join('/')
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized file access attempt', { path })
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if this is a cloud file (path starts with 's3/' or 'blob/')
|
||||
const userId = session.user.id
|
||||
const fullPath = path.join('/')
|
||||
const isS3Path = path[0] === 's3'
|
||||
const isBlobPath = path[0] === 'blob'
|
||||
const isCloudPath = isS3Path || isBlobPath
|
||||
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
|
||||
const isExecutionFile = cloudKey.split('/').length >= 3 && !cloudKey.startsWith('kb/')
|
||||
|
||||
// Use cloud handler if in production, path explicitly specifies cloud storage, or we're using cloud storage
|
||||
if (isUsingCloudStorage() || isCloudPath) {
|
||||
// Extract the actual key (remove 's3/' or 'blob/' prefix if present)
|
||||
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
|
||||
|
||||
// Get bucket type from query parameter
|
||||
const bucketType = request.nextUrl.searchParams.get('bucket')
|
||||
|
||||
return await handleCloudProxy(cloudKey, bucketType)
|
||||
if (!isExecutionFile) {
|
||||
logger.info('Authenticated file access granted', { userId, path: cloudKey })
|
||||
}
|
||||
|
||||
// Use local handler for local files
|
||||
return await handleLocalFile(fullPath)
|
||||
if (isUsingCloudStorage() || isCloudPath) {
|
||||
const bucketType = request.nextUrl.searchParams.get('bucket')
|
||||
return await handleCloudProxy(cloudKey, bucketType, userId)
|
||||
}
|
||||
|
||||
return await handleLocalFile(fullPath, userId)
|
||||
} catch (error) {
|
||||
logger.error('Error serving file:', error)
|
||||
|
||||
@@ -63,10 +64,7 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle local file serving
|
||||
*/
|
||||
async function handleLocalFile(filename: string): Promise<NextResponse> {
|
||||
async function handleLocalFile(filename: string, userId: string): Promise<NextResponse> {
|
||||
try {
|
||||
const filePath = findLocalFile(filename)
|
||||
|
||||
@@ -77,6 +75,8 @@ async function handleLocalFile(filename: string): Promise<NextResponse> {
|
||||
const fileBuffer = await readFile(filePath)
|
||||
const contentType = getContentType(filename)
|
||||
|
||||
logger.info('Local file served', { userId, filename, size: fileBuffer.length })
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
@@ -112,12 +112,10 @@ async function downloadKBFile(cloudKey: string): Promise<Buffer> {
|
||||
throw new Error(`Unsupported storage provider for KB files: ${storageProvider}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy cloud file through our server
|
||||
*/
|
||||
async function handleCloudProxy(
|
||||
cloudKey: string,
|
||||
bucketType?: string | null
|
||||
bucketType?: string | null,
|
||||
userId?: string
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
// Check if this is a KB file (starts with 'kb/')
|
||||
@@ -156,6 +154,13 @@ async function handleCloudProxy(
|
||||
const originalFilename = cloudKey.split('/').pop() || 'download'
|
||||
const contentType = getContentType(originalFilename)
|
||||
|
||||
logger.info('Cloud file served', {
|
||||
userId,
|
||||
key: cloudKey,
|
||||
size: fileBuffer.length,
|
||||
bucket: bucketType || 'default',
|
||||
})
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow as workflowTable } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { applyAutoLayout } from '@/lib/workflows/autolayout'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
type NormalizedWorkflowData,
|
||||
} from '@/lib/workflows/db-helpers'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -77,11 +74,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`)
|
||||
@@ -89,24 +83,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
// Check if user has permission to update this workflow
|
||||
let canUpdate = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
canUpdate = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canUpdate && workflowData.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
canUpdate = true
|
||||
}
|
||||
}
|
||||
const canUpdate =
|
||||
accessContext?.isOwner ||
|
||||
(workflowData.workspaceId
|
||||
? accessContext?.workspacePermission === 'write' ||
|
||||
accessContext?.workspacePermission === 'admin'
|
||||
: false)
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
|
||||
@@ -47,17 +47,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Duplicate workflow and all related data in a transaction
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// First verify the source workflow exists
|
||||
const sourceWorkflow = await tx
|
||||
const sourceWorkflowRow = await tx
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, sourceWorkflowId))
|
||||
.limit(1)
|
||||
|
||||
if (sourceWorkflow.length === 0) {
|
||||
if (sourceWorkflowRow.length === 0) {
|
||||
throw new Error('Source workflow not found')
|
||||
}
|
||||
|
||||
const source = sourceWorkflow[0]
|
||||
const source = sourceWorkflowRow[0]
|
||||
|
||||
// Check if user has permission to access the source workflow
|
||||
let canAccessSource = false
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
workflowHasResponseBlock,
|
||||
} from '@/lib/workflows/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import {
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
processApiWorkflowField,
|
||||
} from '@/app/api/workflows/utils'
|
||||
import { Executor } from '@/executor'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
@@ -74,16 +78,13 @@ function resolveOutputIds(
|
||||
return selectedOutputs
|
||||
}
|
||||
|
||||
// UUID regex to detect if it's already in blockId_attribute format
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
|
||||
|
||||
return selectedOutputs.map((outputId) => {
|
||||
// If it starts with a UUID, it's already in blockId_attribute format (from chat deployments)
|
||||
if (UUID_REGEX.test(outputId)) {
|
||||
return outputId
|
||||
}
|
||||
|
||||
// Otherwise, it's in blockName.path format from the user/API
|
||||
const dotIndex = outputId.indexOf('.')
|
||||
if (dotIndex === -1) {
|
||||
logger.warn(`Invalid output ID format (missing dot): ${outputId}`)
|
||||
@@ -93,7 +94,6 @@ function resolveOutputIds(
|
||||
const blockName = outputId.substring(0, dotIndex)
|
||||
const path = outputId.substring(dotIndex + 1)
|
||||
|
||||
// Find the block by name (case-insensitive, ignoring spaces)
|
||||
const normalizedBlockName = blockName.toLowerCase().replace(/\s+/g, '')
|
||||
const block = Object.values(blocks).find((b: any) => {
|
||||
const normalized = (b.name || '').toLowerCase().replace(/\s+/g, '')
|
||||
@@ -137,9 +137,6 @@ export async function executeWorkflow(
|
||||
|
||||
const loggingSession = new LoggingSession(workflowId, executionId, 'api', requestId)
|
||||
|
||||
// Rate limiting is now handled before entering the sync queue
|
||||
|
||||
// Check if the actor has exceeded their usage limits
|
||||
const usageCheck = await checkServerSideUsageLimits(actorUserId)
|
||||
if (usageCheck.isExceeded) {
|
||||
logger.warn(`[${requestId}] User ${workflow.userId} has exceeded usage limits`, {
|
||||
@@ -151,13 +148,11 @@ export async function executeWorkflow(
|
||||
)
|
||||
}
|
||||
|
||||
// Log input to help debug
|
||||
logger.info(
|
||||
`[${requestId}] Executing workflow with input:`,
|
||||
input ? JSON.stringify(input, null, 2) : 'No input provided'
|
||||
)
|
||||
|
||||
// Use input directly for API workflows
|
||||
const processedInput = input
|
||||
logger.info(
|
||||
`[${requestId}] Using input directly for workflow:`,
|
||||
@@ -168,10 +163,7 @@ export async function executeWorkflow(
|
||||
runningExecutions.add(executionKey)
|
||||
logger.info(`[${requestId}] Starting workflow execution: ${workflowId}`)
|
||||
|
||||
// Load workflow data from deployed state for API executions
|
||||
const deployedData = await loadDeployedWorkflowState(workflowId)
|
||||
|
||||
// Use deployed data as primary source for API executions
|
||||
const { blocks, edges, loops, parallels } = deployedData
|
||||
logger.info(`[${requestId}] Using deployed state for workflow execution: ${workflowId}`)
|
||||
logger.debug(`[${requestId}] Deployed data loaded:`, {
|
||||
@@ -181,10 +173,8 @@ export async function executeWorkflow(
|
||||
parallelsCount: Object.keys(parallels || {}).length,
|
||||
})
|
||||
|
||||
// Use the same execution flow as in scheduled executions
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// Load personal (for the executing user) and workspace env (workspace overrides personal)
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
actorUserId,
|
||||
workflow.workspaceId || undefined
|
||||
@@ -197,7 +187,6 @@ export async function executeWorkflow(
|
||||
variables,
|
||||
})
|
||||
|
||||
// Replace environment variables in the block states
|
||||
const currentBlockStates = await Object.entries(mergedStates).reduce(
|
||||
async (accPromise, [id, block]) => {
|
||||
const acc = await accPromise
|
||||
@@ -206,13 +195,11 @@ export async function executeWorkflow(
|
||||
const subAcc = await subAccPromise
|
||||
let value = subBlock.value
|
||||
|
||||
// If the value is a string and contains environment variable syntax
|
||||
if (typeof value === 'string' && value.includes('{{') && value.includes('}}')) {
|
||||
const matches = value.match(/{{([^}]+)}}/g)
|
||||
if (matches) {
|
||||
// Process all matches sequentially
|
||||
for (const match of matches) {
|
||||
const varName = match.slice(2, -2) // Remove {{ and }}
|
||||
const varName = match.slice(2, -2)
|
||||
const encryptedValue = variables[varName]
|
||||
if (!encryptedValue) {
|
||||
throw new Error(`Environment variable "${varName}" was not found`)
|
||||
@@ -244,7 +231,6 @@ export async function executeWorkflow(
|
||||
Promise.resolve({} as Record<string, Record<string, any>>)
|
||||
)
|
||||
|
||||
// Create a map of decrypted environment variables
|
||||
const decryptedEnvVars: Record<string, string> = {}
|
||||
for (const [key, encryptedValue] of Object.entries(variables)) {
|
||||
try {
|
||||
@@ -256,22 +242,17 @@ export async function executeWorkflow(
|
||||
}
|
||||
}
|
||||
|
||||
// Process the block states to ensure response formats are properly parsed
|
||||
const processedBlockStates = Object.entries(currentBlockStates).reduce(
|
||||
(acc, [blockId, blockState]) => {
|
||||
// Check if this block has a responseFormat that needs to be parsed
|
||||
if (blockState.responseFormat && typeof blockState.responseFormat === 'string') {
|
||||
const responseFormatValue = blockState.responseFormat.trim()
|
||||
|
||||
// Check for variable references like <start.input>
|
||||
if (responseFormatValue.startsWith('<') && responseFormatValue.includes('>')) {
|
||||
logger.debug(
|
||||
`[${requestId}] Response format contains variable reference for block ${blockId}`
|
||||
)
|
||||
// Keep variable references as-is - they will be resolved during execution
|
||||
acc[blockId] = blockState
|
||||
} else if (responseFormatValue === '') {
|
||||
// Empty string - remove response format
|
||||
acc[blockId] = {
|
||||
...blockState,
|
||||
responseFormat: undefined,
|
||||
@@ -279,7 +260,6 @@ export async function executeWorkflow(
|
||||
} else {
|
||||
try {
|
||||
logger.debug(`[${requestId}] Parsing responseFormat for block ${blockId}`)
|
||||
// Attempt to parse the responseFormat if it's a string
|
||||
const parsedResponseFormat = JSON.parse(responseFormatValue)
|
||||
|
||||
acc[blockId] = {
|
||||
@@ -291,7 +271,6 @@ export async function executeWorkflow(
|
||||
`[${requestId}] Failed to parse responseFormat for block ${blockId}, using undefined`,
|
||||
error
|
||||
)
|
||||
// Set to undefined instead of keeping malformed JSON - this allows execution to continue
|
||||
acc[blockId] = {
|
||||
...blockState,
|
||||
responseFormat: undefined,
|
||||
@@ -306,7 +285,6 @@ export async function executeWorkflow(
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Get workflow variables - they are stored as JSON objects in the database
|
||||
const workflowVariables = (workflow.variables as Record<string, any>) || {}
|
||||
|
||||
if (Object.keys(workflowVariables).length > 0) {
|
||||
@@ -317,20 +295,15 @@ export async function executeWorkflow(
|
||||
logger.debug(`[${requestId}] No workflow variables found for: ${workflowId}`)
|
||||
}
|
||||
|
||||
// Serialize and execute the workflow
|
||||
logger.debug(`[${requestId}] Serializing workflow: ${workflowId}`)
|
||||
const serializedWorkflow = new Serializer().serializeWorkflow(
|
||||
mergedStates,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
true // Enable validation during execution
|
||||
true
|
||||
)
|
||||
|
||||
// Determine trigger start block based on execution type
|
||||
// - 'chat': For chat deployments (looks for chat_trigger block)
|
||||
// - 'api': For direct API execution (looks for api_trigger block)
|
||||
// streamConfig is passed from POST handler when using streaming/chat
|
||||
const preferredTriggerType = streamConfig?.workflowTriggerType || 'api'
|
||||
const startBlock = TriggerUtils.findStartBlock(mergedStates, preferredTriggerType, false)
|
||||
|
||||
@@ -346,8 +319,6 @@ export async function executeWorkflow(
|
||||
const startBlockId = startBlock.blockId
|
||||
const triggerBlock = startBlock.block
|
||||
|
||||
// Check if the API trigger has any outgoing connections (except for legacy starter blocks)
|
||||
// Legacy starter blocks have their own validation in the executor
|
||||
if (triggerBlock.type !== 'starter') {
|
||||
const outgoingConnections = serializedWorkflow.connections.filter(
|
||||
(conn) => conn.source === startBlockId
|
||||
@@ -358,14 +329,12 @@ export async function executeWorkflow(
|
||||
}
|
||||
}
|
||||
|
||||
// Build context extensions
|
||||
const contextExtensions: any = {
|
||||
executionId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
isDeployedContext: true,
|
||||
}
|
||||
|
||||
// Add streaming configuration if enabled
|
||||
if (streamConfig?.enabled) {
|
||||
contextExtensions.stream = true
|
||||
contextExtensions.selectedOutputs = streamConfig.selectedOutputs || []
|
||||
@@ -386,10 +355,8 @@ export async function executeWorkflow(
|
||||
contextExtensions,
|
||||
})
|
||||
|
||||
// Set up logging on the executor
|
||||
loggingSession.setupExecutor(executor)
|
||||
|
||||
// Execute workflow (will always return ExecutionResult since we don't use onStream)
|
||||
const result = (await executor.execute(workflowId, startBlockId)) as ExecutionResult
|
||||
|
||||
logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, {
|
||||
@@ -397,14 +364,11 @@ export async function executeWorkflow(
|
||||
executionTime: result.metadata?.duration,
|
||||
})
|
||||
|
||||
// Build trace spans from execution result (works for both success and failure)
|
||||
const { traceSpans, totalDuration } = buildTraceSpans(result)
|
||||
|
||||
// Update workflow run counts if execution was successful
|
||||
if (result.success) {
|
||||
await updateWorkflowRunCounts(workflowId)
|
||||
|
||||
// Track API call in user stats
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
@@ -419,9 +383,9 @@ export async function executeWorkflow(
|
||||
totalDurationMs: totalDuration || 0,
|
||||
finalOutput: result.output || {},
|
||||
traceSpans: (traceSpans || []) as any,
|
||||
workflowInput: processedInput,
|
||||
})
|
||||
|
||||
// For non-streaming, return the execution result
|
||||
return result
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, error)
|
||||
@@ -461,22 +425,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse(validation.error.message, validation.error.status)
|
||||
}
|
||||
|
||||
// Determine trigger type based on authentication
|
||||
let triggerType: TriggerType = 'manual'
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
// Check for API key
|
||||
const apiKeyHeader = request.headers.get('X-API-Key')
|
||||
if (apiKeyHeader) {
|
||||
triggerType = 'api'
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Async execution is now handled in the POST handler below
|
||||
|
||||
// Synchronous execution
|
||||
try {
|
||||
// Resolve actor user id
|
||||
let actorUserId: string | null = null
|
||||
if (triggerType === 'manual') {
|
||||
actorUserId = session!.user!.id
|
||||
@@ -491,7 +449,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
void updateApiKeyLastUsed(auth.keyId).catch(() => {})
|
||||
}
|
||||
|
||||
// Check rate limits BEFORE entering execution for API requests
|
||||
const userSubscription = await getHighestPrioritySubscription(actorUserId)
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
@@ -514,13 +471,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
actorUserId as string
|
||||
)
|
||||
|
||||
// Check if the workflow execution contains a response block output
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
if (hasResponseBlock) {
|
||||
return createHttpResponseFromBlock(result)
|
||||
}
|
||||
|
||||
// Filter out logs and workflowConnections from the API response
|
||||
const filteredResult = createFilteredResult(result)
|
||||
return createSuccessResponse(filteredResult)
|
||||
} catch (error: any) {
|
||||
@@ -536,12 +491,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error executing workflow: ${id}`, error)
|
||||
|
||||
// Check if this is a rate limit error
|
||||
if (error instanceof RateLimitError) {
|
||||
return createErrorResponse(error.message, error.statusCode, 'RATE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
// Check if this is a usage limit error
|
||||
if (error instanceof UsageLimitError) {
|
||||
return createErrorResponse(error.message, error.statusCode, 'USAGE_LIMIT_EXCEEDED')
|
||||
}
|
||||
@@ -566,18 +519,15 @@ export async function POST(
|
||||
const workflowId = id
|
||||
|
||||
try {
|
||||
// Validate workflow access
|
||||
const validation = await validateWorkflowAccess(request as NextRequest, id)
|
||||
if (validation.error) {
|
||||
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
|
||||
return createErrorResponse(validation.error.message, validation.error.status)
|
||||
}
|
||||
|
||||
// Check execution mode from header
|
||||
const executionMode = request.headers.get('X-Execution-Mode')
|
||||
const isAsync = executionMode === 'async'
|
||||
|
||||
// Parse request body first to check for internal parameters
|
||||
const body = await request.text()
|
||||
logger.info(`[${requestId}] ${body ? 'Request body provided' : 'No request body provided'}`)
|
||||
|
||||
@@ -616,17 +566,78 @@ export async function POST(
|
||||
streamResponse,
|
||||
selectedOutputs,
|
||||
workflowTriggerType,
|
||||
input,
|
||||
input: rawInput,
|
||||
} = extractExecutionParams(request as NextRequest, parsedBody)
|
||||
|
||||
// Get authenticated user and determine trigger type
|
||||
let processedInput = rawInput
|
||||
logger.info(`[${requestId}] Raw input received:`, JSON.stringify(rawInput, null, 2))
|
||||
|
||||
try {
|
||||
const deployedData = await loadDeployedWorkflowState(workflowId)
|
||||
const blocks = deployedData.blocks || {}
|
||||
logger.info(`[${requestId}] Loaded ${Object.keys(blocks).length} blocks from workflow`)
|
||||
|
||||
const apiTriggerBlock = Object.values(blocks).find(
|
||||
(block: any) => block.type === 'api_trigger'
|
||||
) as any
|
||||
logger.info(`[${requestId}] API trigger block found:`, !!apiTriggerBlock)
|
||||
|
||||
if (apiTriggerBlock?.subBlocks?.inputFormat?.value) {
|
||||
const inputFormat = apiTriggerBlock.subBlocks.inputFormat.value as Array<{
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
||||
}>
|
||||
logger.info(
|
||||
`[${requestId}] Input format fields:`,
|
||||
inputFormat.map((f) => `${f.name}:${f.type}`).join(', ')
|
||||
)
|
||||
|
||||
const fileFields = inputFormat.filter((field) => field.type === 'files')
|
||||
logger.info(`[${requestId}] Found ${fileFields.length} file-type fields`)
|
||||
|
||||
if (fileFields.length > 0 && typeof rawInput === 'object' && rawInput !== null) {
|
||||
const executionContext = {
|
||||
workspaceId: validation.workflow.workspaceId,
|
||||
workflowId,
|
||||
}
|
||||
|
||||
for (const fileField of fileFields) {
|
||||
const fieldValue = rawInput[fileField.name]
|
||||
|
||||
if (fieldValue && typeof fieldValue === 'object') {
|
||||
const uploadedFiles = await processApiWorkflowField(
|
||||
fieldValue,
|
||||
executionContext,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
processedInput = {
|
||||
...processedInput,
|
||||
[fileField.name]: uploadedFiles,
|
||||
}
|
||||
logger.info(
|
||||
`[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to process file uploads:`, error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to process file uploads'
|
||||
return createErrorResponse(errorMessage, 400)
|
||||
}
|
||||
|
||||
const input = processedInput
|
||||
|
||||
let authenticatedUserId: string
|
||||
let triggerType: TriggerType = 'manual'
|
||||
|
||||
// For internal calls (chat deployments), use the workflow owner's ID
|
||||
if (finalIsSecureMode) {
|
||||
authenticatedUserId = validation.workflow.userId
|
||||
triggerType = 'manual' // Chat deployments use manual trigger type (no rate limit)
|
||||
triggerType = 'manual'
|
||||
} else {
|
||||
const session = await getSession()
|
||||
const apiKeyHeader = request.headers.get('X-API-Key')
|
||||
@@ -649,7 +660,6 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Get user subscription (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
|
||||
|
||||
if (isAsync) {
|
||||
@@ -659,7 +669,7 @@ export async function POST(
|
||||
authenticatedUserId,
|
||||
userSubscription,
|
||||
'api',
|
||||
true // isAsync = true
|
||||
true
|
||||
)
|
||||
|
||||
if (!rateLimitCheck.allowed) {
|
||||
@@ -683,7 +693,6 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Rate limit passed - always use Trigger.dev for async executions
|
||||
const handle = await tasks.trigger('workflow-execution', {
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
@@ -723,7 +732,7 @@ export async function POST(
|
||||
authenticatedUserId,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false // isAsync = false for sync calls
|
||||
false
|
||||
)
|
||||
|
||||
if (!rateLimitCheck.allowed) {
|
||||
@@ -732,15 +741,12 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Handle streaming response - wrap execution in SSE stream
|
||||
if (streamResponse) {
|
||||
// Load workflow blocks to resolve output IDs from blockName.attribute to blockId_attribute format
|
||||
const deployedData = await loadDeployedWorkflowState(workflowId)
|
||||
const resolvedSelectedOutputs = selectedOutputs
|
||||
? resolveOutputIds(selectedOutputs, deployedData.blocks || {})
|
||||
: selectedOutputs
|
||||
|
||||
// Use shared streaming response creator
|
||||
const { createStreamingResponse } = await import('@/lib/workflows/streaming')
|
||||
const { SSE_HEADERS } = await import('@/lib/utils')
|
||||
|
||||
@@ -763,7 +769,6 @@ export async function POST(
|
||||
})
|
||||
}
|
||||
|
||||
// Non-streaming execution
|
||||
const result = await executeWorkflow(
|
||||
validation.workflow,
|
||||
requestId,
|
||||
@@ -772,13 +777,11 @@ export async function POST(
|
||||
undefined
|
||||
)
|
||||
|
||||
// Non-streaming response
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
if (hasResponseBlock) {
|
||||
return createHttpResponseFromBlock(result)
|
||||
}
|
||||
|
||||
// Filter out logs and workflowConnections from the API response
|
||||
const filteredResult = createFilteredResult(result)
|
||||
return createSuccessResponse(filteredResult)
|
||||
} catch (error: any) {
|
||||
@@ -794,17 +797,14 @@ export async function POST(
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error executing workflow: ${workflowId}`, error)
|
||||
|
||||
// Check if this is a rate limit error
|
||||
if (error instanceof RateLimitError) {
|
||||
return createErrorResponse(error.message, error.statusCode, 'RATE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
// Check if this is a usage limit error
|
||||
if (error instanceof UsageLimitError) {
|
||||
return createErrorResponse(error.message, error.statusCode, 'USAGE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
// Check if this is a rate limit error (string match for backward compatibility)
|
||||
if (error.message?.includes('Rate limit exceeded')) {
|
||||
return createErrorResponse(error.message, 429, 'RATE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ describe('Workflow By ID API Route', () => {
|
||||
error: vi.fn(),
|
||||
}
|
||||
|
||||
const mockGetWorkflowById = vi.fn()
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
@@ -30,6 +33,20 @@ describe('Workflow By ID API Route', () => {
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
mockGetWorkflowById.mockReset()
|
||||
mockGetWorkflowAccessContext.mockReset()
|
||||
|
||||
vi.doMock('@/lib/workflows/utils', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/lib/workflows/utils')>('@/lib/workflows/utils')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getWorkflowById: mockGetWorkflowById,
|
||||
getWorkflowAccessContext: mockGetWorkflowAccessContext,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,17 +77,14 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(null)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: null,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent')
|
||||
const params = Promise.resolve({ id: 'nonexistent' })
|
||||
@@ -105,22 +119,28 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
|
||||
}))
|
||||
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
@@ -154,22 +174,28 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
|
||||
}))
|
||||
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'read',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(false),
|
||||
@@ -200,22 +226,14 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue(null),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(false),
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
@@ -250,17 +268,14 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
|
||||
@@ -294,19 +309,22 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
}),
|
||||
},
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
@@ -340,24 +358,22 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(true),
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
@@ -391,22 +407,14 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(false),
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
@@ -432,6 +440,7 @@ describe('Workflow By ID API Route', () => {
|
||||
}
|
||||
|
||||
const updateData = { name: 'Updated Workflow' }
|
||||
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
@@ -439,23 +448,26 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ ...mockWorkflow, ...updateData }]),
|
||||
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
@@ -481,6 +493,7 @@ describe('Workflow By ID API Route', () => {
|
||||
}
|
||||
|
||||
const updateData = { name: 'Updated Workflow' }
|
||||
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
@@ -488,28 +501,26 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'write',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ ...mockWorkflow, ...updateData }]),
|
||||
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('write'),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(false),
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
@@ -542,22 +553,14 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(false),
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'read',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
@@ -587,17 +590,14 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockResolvedValue(mockWorkflow),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
mockGetWorkflowById.mockResolvedValueOnce(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Invalid data - empty name
|
||||
const invalidData = { name: '' }
|
||||
@@ -625,17 +625,7 @@ describe('Workflow By ID API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
then: vi.fn().mockRejectedValue(new Error('Database connection timeout')),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
mockGetWorkflowById.mockRejectedValueOnce(new Error('Database connection timeout'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
@@ -8,9 +8,9 @@ import { getSession } from '@/lib/auth'
|
||||
import { verifyInternalToken } from '@/lib/auth/internal'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { getWorkflowAccessContext, getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowByIdAPI')
|
||||
|
||||
@@ -74,12 +74,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
userId = authenticatedUserId
|
||||
}
|
||||
|
||||
// Fetch the workflow
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
let accessContext = null
|
||||
let workflowData = await getWorkflowById(workflowId)
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
@@ -94,18 +90,21 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
hasAccess = true
|
||||
} else {
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
hasAccess = true
|
||||
}
|
||||
if (workflowData) {
|
||||
accessContext = await getWorkflowAccessContext(workflowId, userId ?? undefined)
|
||||
|
||||
// Case 2: Workflow belongs to a workspace the user has permissions for
|
||||
if (!hasAccess && workflowData.workspaceId && userId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (userPermission !== null) {
|
||||
if (!accessContext) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
workflowData = accessContext.workflow
|
||||
|
||||
if (accessContext.isOwner) {
|
||||
hasAccess = true
|
||||
}
|
||||
|
||||
if (!hasAccess && workflowData.workspaceId && accessContext.workspacePermission) {
|
||||
hasAccess = true
|
||||
}
|
||||
}
|
||||
@@ -179,11 +178,8 @@ export async function DELETE(
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow || (await getWorkflowById(workflowId))
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`)
|
||||
@@ -200,8 +196,8 @@ export async function DELETE(
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has admin permission
|
||||
if (!canDelete && workflowData.workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(userId, workflowData.workspaceId)
|
||||
if (hasAdmin) {
|
||||
const context = accessContext || (await getWorkflowAccessContext(workflowId, userId))
|
||||
if (context?.workspacePermission === 'admin') {
|
||||
canDelete = true
|
||||
}
|
||||
}
|
||||
@@ -320,11 +316,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const updates = UpdateWorkflowSchema.parse(body)
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow || (await getWorkflowById(workflowId))
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`)
|
||||
@@ -341,12 +334,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canUpdate && workflowData.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
const context = accessContext || (await getWorkflowAccessContext(workflowId, userId))
|
||||
if (context?.workspacePermission === 'write' || context?.workspacePermission === 'admin') {
|
||||
canUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
|
||||
|
||||
const logger = createLogger('WorkflowStateAPI')
|
||||
@@ -124,11 +124,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const state = WorkflowStateSchema.parse(body)
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`)
|
||||
@@ -136,24 +133,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
// Check if user has permission to update this workflow
|
||||
let canUpdate = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
canUpdate = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canUpdate && workflowData.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
canUpdate = true
|
||||
}
|
||||
}
|
||||
const canUpdate =
|
||||
accessContext?.isOwner ||
|
||||
(workflowData.workspaceId
|
||||
? accessContext?.workspacePermission === 'write' ||
|
||||
accessContext?.workspacePermission === 'admin'
|
||||
: false)
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
|
||||
@@ -18,12 +18,18 @@ import {
|
||||
describe('Workflow Variables API Route', () => {
|
||||
let authMocks: ReturnType<typeof mockAuth>
|
||||
let databaseMocks: ReturnType<typeof createMockDatabase>
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setupCommonApiMocks()
|
||||
mockCryptoUuid('mock-request-id-12345678')
|
||||
authMocks = mockAuth(mockUser)
|
||||
mockGetWorkflowAccessContext.mockReset()
|
||||
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowAccessContext: mockGetWorkflowAccessContext,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -47,9 +53,7 @@ describe('Workflow Variables API Route', () => {
|
||||
|
||||
it('should return 404 when workflow does not exist', async () => {
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[]] }, // No workflow found
|
||||
})
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce(null)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent/variables')
|
||||
const params = Promise.resolve({ id: 'nonexistent' })
|
||||
@@ -73,8 +77,12 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[mockWorkflow]] },
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
@@ -99,14 +107,14 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[mockWorkflow]] },
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-owner',
|
||||
workspacePermission: 'read',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
@@ -116,14 +124,6 @@ describe('Workflow Variables API Route', () => {
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.data).toEqual(mockWorkflow.variables)
|
||||
|
||||
// Verify permissions check was called
|
||||
const { getUserEntityPermissions } = await import('@/lib/permissions/utils')
|
||||
expect(getUserEntityPermissions).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
'workspace',
|
||||
'workspace-456'
|
||||
)
|
||||
})
|
||||
|
||||
it('should deny access when user has no workspace permissions', async () => {
|
||||
@@ -135,14 +135,14 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[mockWorkflow]] },
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-owner',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
@@ -165,8 +165,12 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[mockWorkflow]] },
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
@@ -191,8 +195,15 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[mockWorkflow]] },
|
||||
update: { results: [{}] },
|
||||
})
|
||||
|
||||
@@ -223,14 +234,14 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[mockWorkflow]] },
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-owner',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const variables = [
|
||||
{ id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' },
|
||||
]
|
||||
@@ -258,8 +269,12 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { results: [[mockWorkflow]] },
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Invalid data - missing required fields
|
||||
@@ -283,9 +298,7 @@ describe('Workflow Variables API Route', () => {
|
||||
describe('Error handling', () => {
|
||||
it.concurrent('should handle database errors gracefully', async () => {
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
databaseMocks = createMockDatabase({
|
||||
select: { throwError: true, errorMessage: 'Database connection failed' },
|
||||
})
|
||||
mockGetWorkflowAccessContext.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
@@ -5,8 +5,8 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
|
||||
const logger = createLogger('WorkflowVariablesAPI')
|
||||
@@ -35,32 +35,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
// Get the workflow record
|
||||
const workflowRecord = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, session.user.id)
|
||||
const workflowData = accessContext?.workflow
|
||||
|
||||
if (!workflowRecord.length) {
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowData = workflowRecord[0]
|
||||
const workspaceId = workflowData.workspaceId
|
||||
|
||||
// Check authorization - either the user owns the workflow or has workspace permissions
|
||||
let isAuthorized = workflowData.userId === session.user.id
|
||||
|
||||
// If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions
|
||||
if (!isAuthorized && workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
isAuthorized = userPermission !== null
|
||||
}
|
||||
const isAuthorized =
|
||||
accessContext?.isOwner || (workspaceId ? accessContext?.workspacePermission !== null : false)
|
||||
|
||||
if (!isAuthorized) {
|
||||
logger.warn(
|
||||
@@ -125,32 +111,18 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
// Get the workflow record
|
||||
const workflowRecord = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, session.user.id)
|
||||
const workflowData = accessContext?.workflow
|
||||
|
||||
if (!workflowRecord.length) {
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowData = workflowRecord[0]
|
||||
const workspaceId = workflowData.workspaceId
|
||||
|
||||
// Check authorization - either the user owns the workflow or has workspace permissions
|
||||
let isAuthorized = workflowData.userId === session.user.id
|
||||
|
||||
// If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions
|
||||
if (!isAuthorized && workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
isAuthorized = userPermission !== null
|
||||
}
|
||||
const isAuthorized =
|
||||
accessContext?.isOwner || (workspaceId ? accessContext?.workspacePermission !== null : false)
|
||||
|
||||
if (!isAuthorized) {
|
||||
logger.warn(
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { uploadExecutionFile } from '@/lib/workflows/execution-file-storage'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
export function createErrorResponse(error: string, status: number, code?: string) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -37,3 +42,99 @@ export async function verifyWorkspaceMembership(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process API workflow files - handles both base64 ('file' type) and URL pass-through ('url' type)
|
||||
*/
|
||||
export async function processApiWorkflowFiles(
|
||||
file: { type: string; data: string; name: string; mime?: string },
|
||||
executionContext: { workspaceId: string; workflowId: string; executionId: string },
|
||||
requestId: string
|
||||
): Promise<UserFile | null> {
|
||||
if (file.type === 'file' && file.data && file.name) {
|
||||
const dataUrlPrefix = 'data:'
|
||||
const base64Prefix = ';base64,'
|
||||
|
||||
if (!file.data.startsWith(dataUrlPrefix)) {
|
||||
logger.warn(`[${requestId}] Invalid data format for file: ${file.name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const base64Index = file.data.indexOf(base64Prefix)
|
||||
if (base64Index === -1) {
|
||||
logger.warn(`[${requestId}] Invalid data format (no base64 marker) for file: ${file.name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = file.data.substring(dataUrlPrefix.length, base64Index)
|
||||
const base64Data = file.data.substring(base64Index + base64Prefix.length)
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
if (buffer.length > MAX_FILE_SIZE) {
|
||||
const fileSizeMB = (buffer.length / (1024 * 1024)).toFixed(2)
|
||||
throw new Error(
|
||||
`File "${file.name}" exceeds the maximum size limit of 20MB (actual size: ${fileSizeMB}MB)`
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Uploading file: ${file.name} (${buffer.length} bytes)`)
|
||||
|
||||
const userFile = await uploadExecutionFile(
|
||||
executionContext,
|
||||
buffer,
|
||||
file.name,
|
||||
mimeType || file.mime || 'application/octet-stream'
|
||||
)
|
||||
|
||||
logger.debug(`[${requestId}] Successfully uploaded ${file.name}`)
|
||||
return userFile
|
||||
}
|
||||
|
||||
if (file.type === 'url' && file.data) {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
url: file.data,
|
||||
name: file.name,
|
||||
size: 0,
|
||||
type: file.mime || 'application/octet-stream',
|
||||
key: `url/${file.name}`,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all files for a given field in the API workflow input
|
||||
*/
|
||||
export async function processApiWorkflowField(
|
||||
fieldValue: any,
|
||||
executionContext: { workspaceId: string; workflowId: string },
|
||||
requestId: string
|
||||
): Promise<UserFile[]> {
|
||||
if (!fieldValue || typeof fieldValue !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const files = Array.isArray(fieldValue) ? fieldValue : [fieldValue]
|
||||
const uploadedFiles: UserFile[] = []
|
||||
const executionId = uuidv4()
|
||||
const fullContext = { ...executionContext, executionId }
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const userFile = await processApiWorkflowFiles(file, fullContext, requestId)
|
||||
|
||||
if (userFile) {
|
||||
uploadedFiles.push(userFile)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to process file ${file.name}:`, error)
|
||||
throw new Error(`Failed to upload file: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedFiles
|
||||
}
|
||||
|
||||
@@ -45,6 +45,18 @@ const DEFAULT_VOICE_SETTINGS = {
|
||||
voiceId: 'EXAVITQu4vr4xnSDxMaL', // Default ElevenLabs voice (Bella)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a File object to a base64 data URL
|
||||
*/
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an audio stream handler for text-to-speech conversion
|
||||
* @param streamTextToAudio - Function to stream text to audio
|
||||
@@ -265,20 +277,43 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}
|
||||
|
||||
// Handle sending a message
|
||||
const handleSendMessage = async (messageParam?: string, isVoiceInput = false) => {
|
||||
const handleSendMessage = async (
|
||||
messageParam?: string,
|
||||
isVoiceInput = false,
|
||||
files?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
file: File
|
||||
dataUrl?: string
|
||||
}>
|
||||
) => {
|
||||
const messageToSend = messageParam ?? inputValue
|
||||
if (!messageToSend.trim() || isLoading) return
|
||||
if ((!messageToSend.trim() && (!files || files.length === 0)) || isLoading) return
|
||||
|
||||
logger.info('Sending message:', { messageToSend, isVoiceInput, conversationId })
|
||||
logger.info('Sending message:', {
|
||||
messageToSend,
|
||||
isVoiceInput,
|
||||
conversationId,
|
||||
filesCount: files?.length,
|
||||
})
|
||||
|
||||
// Reset userHasScrolled when sending a new message
|
||||
setUserHasScrolled(false)
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
content: messageToSend,
|
||||
content: messageToSend || (files && files.length > 0 ? `Sent ${files.length} file(s)` : ''),
|
||||
type: 'user',
|
||||
timestamp: new Date(),
|
||||
attachments: files?.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
dataUrl: file.dataUrl || '',
|
||||
})),
|
||||
}
|
||||
|
||||
// Add the user's message to the chat
|
||||
@@ -299,7 +334,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
|
||||
try {
|
||||
// Send structured payload to maintain chat context
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
input:
|
||||
typeof userMessage.content === 'string'
|
||||
? userMessage.content
|
||||
@@ -307,7 +342,22 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
conversationId,
|
||||
}
|
||||
|
||||
logger.info('API payload:', payload)
|
||||
// Add files if present (convert to base64 for JSON transmission)
|
||||
if (files && files.length > 0) {
|
||||
payload.files = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
dataUrl: file.dataUrl || (await fileToBase64(file.file)),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('API payload:', {
|
||||
...payload,
|
||||
files: payload.files ? `${payload.files.length} files` : undefined,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/chat/${identifier}`, {
|
||||
method: 'POST',
|
||||
@@ -499,8 +549,8 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
<div className='relative p-3 pb-4 md:p-4 md:pb-6'>
|
||||
<div className='relative mx-auto max-w-3xl md:max-w-[748px]'>
|
||||
<ChatInput
|
||||
onSubmit={(value, isVoiceInput) => {
|
||||
void handleSendMessage(value, isVoiceInput)
|
||||
onSubmit={(value, isVoiceInput, files) => {
|
||||
void handleSendMessage(value, isVoiceInput, files)
|
||||
}}
|
||||
isStreaming={isStreamingResponse}
|
||||
onStopStreaming={() => stopStreaming(setMessages)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Send, Square } from 'lucide-react'
|
||||
import { AlertCircle, Paperclip, Send, Square, X } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { VoiceInput } from '@/app/chat/components/input/voice-input'
|
||||
|
||||
@@ -12,8 +12,17 @@ const PLACEHOLDER_DESKTOP = 'Enter a message or click the mic to speak'
|
||||
const MAX_TEXTAREA_HEIGHT = 120 // Max height in pixels (e.g., for about 3-4 lines)
|
||||
const MAX_TEXTAREA_HEIGHT_MOBILE = 100 // Smaller for mobile
|
||||
|
||||
interface AttachedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
file: File
|
||||
dataUrl?: string
|
||||
}
|
||||
|
||||
export const ChatInput: React.FC<{
|
||||
onSubmit?: (value: string, isVoiceInput?: boolean) => void
|
||||
onSubmit?: (value: string, isVoiceInput?: boolean, files?: AttachedFile[]) => void
|
||||
isStreaming?: boolean
|
||||
onStopStreaming?: () => void
|
||||
onVoiceStart?: () => void
|
||||
@@ -21,8 +30,11 @@ export const ChatInput: React.FC<{
|
||||
}> = ({ onSubmit, isStreaming = false, onStopStreaming, onVoiceStart, voiceOnly = false }) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null) // Ref for the textarea
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
|
||||
const [uploadErrors, setUploadErrors] = useState<string[]>([])
|
||||
|
||||
// Check if speech-to-text is available in the browser
|
||||
const isSttAvailable =
|
||||
@@ -85,10 +97,75 @@ export const ChatInput: React.FC<{
|
||||
// Focus is now handled by the useEffect above
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles) return
|
||||
|
||||
const newFiles: AttachedFile[] = []
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB limit
|
||||
const maxFiles = 5
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
if (attachedFiles.length + newFiles.length >= maxFiles) break
|
||||
|
||||
const file = selectedFiles[i]
|
||||
|
||||
// Check file size
|
||||
if (file.size > maxSize) {
|
||||
setUploadErrors((prev) => [...prev, `${file.name} is too large (max 10MB)`])
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const isDuplicate = attachedFiles.some(
|
||||
(existingFile) => existingFile.name === file.name && existingFile.size === file.size
|
||||
)
|
||||
if (isDuplicate) {
|
||||
setUploadErrors((prev) => [...prev, `${file.name} already added`])
|
||||
continue
|
||||
}
|
||||
|
||||
// Read file as data URL if it's an image
|
||||
let dataUrl: string | undefined
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
newFiles.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
file,
|
||||
dataUrl,
|
||||
})
|
||||
}
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
setAttachedFiles([...attachedFiles, ...newFiles])
|
||||
setUploadErrors([]) // Clear errors when files are successfully added
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (fileId: string) => {
|
||||
setAttachedFiles(attachedFiles.filter((f) => f.id !== fileId))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!inputValue.trim()) return
|
||||
onSubmit?.(inputValue.trim(), false) // false = not voice input
|
||||
if (!inputValue.trim() && attachedFiles.length === 0) return
|
||||
onSubmit?.(inputValue.trim(), false, attachedFiles) // false = not voice input
|
||||
setInputValue('')
|
||||
setAttachedFiles([])
|
||||
setUploadErrors([]) // Clear errors when sending message
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto' // Reset height after submit
|
||||
textareaRef.current.style.overflowY = 'hidden' // Ensure overflow is hidden
|
||||
@@ -132,6 +209,29 @@ export const ChatInput: React.FC<{
|
||||
<>
|
||||
<div className='fixed right-0 bottom-0 left-0 flex w-full items-center justify-center bg-gradient-to-t from-white to-transparent px-4 pb-4 text-black md:px-0 md:pb-4'>
|
||||
<div ref={wrapperRef} className='w-full max-w-3xl md:max-w-[748px]'>
|
||||
{/* Error Messages */}
|
||||
{uploadErrors.length > 0 && (
|
||||
<div className='mb-3'>
|
||||
<div className='rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-950/20'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1 font-medium text-red-800 text-sm dark:text-red-300'>
|
||||
File upload error
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
{uploadErrors.map((error, idx) => (
|
||||
<div key={idx} className='text-red-700 text-sm dark:text-red-400'>
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Input Area with Controls */}
|
||||
<motion.div
|
||||
className='rounded-2xl border border-gray-200 bg-white shadow-sm md:rounded-3xl'
|
||||
@@ -140,27 +240,99 @@ export const ChatInput: React.FC<{
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className='flex items-center gap-2 p-3 md:p-4'>
|
||||
{/* Voice Input */}
|
||||
{isSttAvailable && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<VoiceInput
|
||||
onVoiceStart={handleVoiceStart}
|
||||
disabled={isStreaming}
|
||||
minimal
|
||||
{/* File Previews */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className='mb-2 flex flex-wrap gap-2 px-3 pt-3 md:px-4'>
|
||||
{attachedFiles.map((file) => {
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`group relative overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800 ${
|
||||
file.dataUrl
|
||||
? 'h-16 w-16 md:h-20 md:w-20'
|
||||
: 'flex h-16 min-w-[120px] max-w-[200px] items-center gap-2 px-2 md:h-20 md:min-w-[140px] md:max-w-[220px] md:px-3'
|
||||
}`}
|
||||
title=''
|
||||
>
|
||||
{file.dataUrl ? (
|
||||
<img
|
||||
src={file.dataUrl}
|
||||
alt={file.name}
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p>Start voice conversation</p>
|
||||
<span className='text-gray-500 text-xs'>Click to enter voice mode</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-gray-100 md:h-10 md:w-10 dark:bg-gray-700'>
|
||||
<Paperclip
|
||||
size={16}
|
||||
className='text-gray-500 md:h-5 md:w-5 dark:text-gray-400'
|
||||
/>
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-gray-800 text-xs dark:text-gray-200'>
|
||||
{file.name}
|
||||
</div>
|
||||
<div className='text-[10px] text-gray-500 dark:text-gray-400'>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleRemoveFile(file.id)}
|
||||
className='absolute top-1 right-1 rounded-full bg-gray-800/80 p-1 text-white opacity-0 transition-opacity hover:bg-gray-800/80 hover:text-white group-hover:opacity-100 dark:bg-black/70 dark:hover:bg-black/70 dark:hover:text-white'
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex items-center gap-2 p-3 md:p-4'>
|
||||
{/* Paperclip Button */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isStreaming || attachedFiles.length >= 5}
|
||||
className='flex items-center justify-center rounded-full p-1.5 text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 md:p-2'
|
||||
>
|
||||
<Paperclip size={16} className='md:h-5 md:w-5' />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p>Attach files</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
handleFileSelect(e.target.files)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}}
|
||||
className='hidden'
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
|
||||
{/* Text Input Container */}
|
||||
<div className='relative flex-1'>
|
||||
@@ -208,10 +380,30 @@ export const ChatInput: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Input */}
|
||||
{isSttAvailable && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<VoiceInput
|
||||
onVoiceStart={handleVoiceStart}
|
||||
disabled={isStreaming}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p>Start voice conversation</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Send Button */}
|
||||
<button
|
||||
className={`flex items-center justify-center rounded-full p-1.5 text-white transition-colors md:p-2 ${
|
||||
inputValue.trim()
|
||||
inputValue.trim() || attachedFiles.length > 0
|
||||
? 'bg-black hover:bg-zinc-700'
|
||||
: 'cursor-default bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
|
||||
@@ -72,19 +72,17 @@ export function VoiceInput({
|
||||
|
||||
if (minimal) {
|
||||
return (
|
||||
<motion.button
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleVoiceClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-center p-1 transition-colors duration-200 ${
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600'
|
||||
className={`flex items-center justify-center rounded-full p-1.5 text-gray-600 transition-colors duration-200 hover:bg-gray-100 md:p-2 ${
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title='Start voice conversation'
|
||||
>
|
||||
<Mic size={18} className='text-gray-500' />
|
||||
</motion.button>
|
||||
<Mic size={16} className='md:h-5 md:w-5' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import MarkdownRenderer from './components/markdown-renderer'
|
||||
|
||||
export interface ChatAttachment {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
dataUrl: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string | Record<string, unknown>
|
||||
@@ -12,6 +20,7 @@ export interface ChatMessage {
|
||||
timestamp: Date
|
||||
isInitialMessage?: boolean
|
||||
isStreaming?: boolean
|
||||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
function EnhancedMarkdownRenderer({ content }: { content: string }) {
|
||||
@@ -39,15 +48,96 @@ export const ClientChatMessage = memo(
|
||||
return (
|
||||
<div className='px-4 py-5' data-message-id={message.id}>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
{/* File attachments displayed above the message */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className='mb-2 flex justify-end'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{message.attachments.map((attachment) => {
|
||||
const isImage = attachment.type.startsWith('image/')
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.includes('pdf'))
|
||||
return (
|
||||
<FileText className='h-5 w-5 text-gray-500 md:h-6 md:w-6 dark:text-gray-400' />
|
||||
)
|
||||
if (type.startsWith('image/'))
|
||||
return (
|
||||
<ImageIcon className='h-5 w-5 text-gray-500 md:h-6 md:w-6 dark:text-gray-400' />
|
||||
)
|
||||
if (type.includes('text') || type.includes('json'))
|
||||
return (
|
||||
<FileText className='h-5 w-5 text-gray-500 md:h-6 md:w-6 dark:text-gray-400' />
|
||||
)
|
||||
return (
|
||||
<FileIcon className='h-5 w-5 text-gray-500 md:h-6 md:w-6 dark:text-gray-400' />
|
||||
)
|
||||
}
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes || bytes === 0) return ''
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`relative overflow-hidden rounded-2xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800 ${
|
||||
attachment.dataUrl?.trim() ? 'cursor-pointer' : ''
|
||||
} ${
|
||||
isImage
|
||||
? 'h-16 w-16 md:h-20 md:w-20'
|
||||
: 'flex h-16 min-w-[140px] max-w-[220px] items-center gap-2 px-3 md:h-20 md:min-w-[160px] md:max-w-[240px]'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (attachment.dataUrl?.trim()) {
|
||||
e.preventDefault()
|
||||
window.open(attachment.dataUrl, '_blank')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.name}
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-gray-100 md:h-12 md:w-12 dark:bg-gray-700'>
|
||||
{getFileIcon(attachment.type)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-gray-800 text-xs md:text-sm dark:text-gray-200'>
|
||||
{attachment.name}
|
||||
</div>
|
||||
{attachment.size && (
|
||||
<div className='text-[10px] text-gray-500 md:text-xs dark:text-gray-400'>
|
||||
{formatFileSize(attachment.size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 dark:bg-gray-600'>
|
||||
<div className='whitespace-pre-wrap break-words text-base text-gray-800 leading-relaxed dark:text-gray-100'>
|
||||
{isJsonObject ? (
|
||||
<pre>{JSON.stringify(message.content, null, 2)}</pre>
|
||||
) : (
|
||||
<span>{message.content as string}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Render text content if present and not just file count message */}
|
||||
{message.content && !String(message.content).startsWith('Sent') && (
|
||||
<div className='whitespace-pre-wrap break-words text-base text-gray-800 leading-relaxed dark:text-gray-100'>
|
||||
{isJsonObject ? (
|
||||
<pre>{JSON.stringify(message.content, null, 2)}</pre>
|
||||
) : (
|
||||
<span>{message.content as string}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,8 +236,8 @@ export function ExampleCommand({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='group relative rounded-md border bg-background transition-colors hover:bg-muted/50'>
|
||||
<pre className='whitespace-pre-wrap p-3 font-mono text-xs'>{getDisplayCommand()}</pre>
|
||||
<div className='group relative overflow-x-auto rounded-md border bg-background transition-colors hover:bg-muted/50'>
|
||||
<pre className='whitespace-pre p-3 font-mono text-xs'>{getDisplayCommand()}</pre>
|
||||
<CopyButton text={getActualCommand()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +139,16 @@ export function DeployModal({
|
||||
case 'array':
|
||||
exampleData[field.name] = [1, 2, 3]
|
||||
break
|
||||
case 'files':
|
||||
exampleData[field.name] = [
|
||||
{
|
||||
data: 'data:application/pdf;base64,...',
|
||||
type: 'file',
|
||||
name: 'document.pdf',
|
||||
mime: 'application/pdf',
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react'
|
||||
import { AlertCircle, ArrowDown, ArrowUp, File, FileText, Image, Paperclip, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Notice } from '@/components/ui/notice'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
parseOutputContentSafely,
|
||||
} from '@/lib/response-format'
|
||||
import {
|
||||
ChatFileUpload,
|
||||
ChatMessage,
|
||||
OutputSelect,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components'
|
||||
@@ -261,12 +259,40 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) {
|
||||
let result: any = null
|
||||
|
||||
try {
|
||||
// Add user message
|
||||
// Read files as data URLs for display in chat (only images to avoid localStorage quota issues)
|
||||
const attachmentsWithData = await Promise.all(
|
||||
chatFiles.map(async (file) => {
|
||||
let dataUrl = ''
|
||||
// Only read images as data URLs to avoid storing large files in localStorage
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file.file)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error reading file as data URL:', error)
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
dataUrl,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Add user message with attachments (include all files, even non-images without dataUrl)
|
||||
addMessage({
|
||||
content:
|
||||
sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : ''),
|
||||
workflowId: activeWorkflowId,
|
||||
type: 'user',
|
||||
attachments: attachmentsWithData,
|
||||
})
|
||||
|
||||
// Prepare workflow input
|
||||
@@ -626,66 +652,212 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) {
|
||||
|
||||
if (validNewFiles.length > 0) {
|
||||
setChatFiles([...chatFiles, ...validNewFiles])
|
||||
setUploadErrors([]) // Clear errors when files are successfully added
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* File upload section */}
|
||||
<div className='mb-2'>
|
||||
{uploadErrors.length > 0 && (
|
||||
<div className='mb-2'>
|
||||
<Notice variant='error' title='File upload error'>
|
||||
<ul className='list-disc pl-5'>
|
||||
{uploadErrors.map((err, idx) => (
|
||||
<li key={idx}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Notice>
|
||||
{/* Error messages */}
|
||||
{uploadErrors.length > 0 && (
|
||||
<div className='mb-2'>
|
||||
<div className='rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-950/20'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1 font-medium text-red-800 text-sm dark:text-red-300'>
|
||||
File upload error
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
{uploadErrors.map((err, idx) => (
|
||||
<div key={idx} className='text-red-700 text-sm dark:text-red-400'>
|
||||
{err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined input container matching copilot style */}
|
||||
<div
|
||||
className={`rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs transition-all duration-200 dark:border-[#414141] dark:bg-[var(--surface-elevated)] ${
|
||||
isDragOver
|
||||
? 'border-[var(--brand-primary-hover-hex)] bg-purple-50/50 dark:border-[var(--brand-primary-hover-hex)] dark:bg-purple-950/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{/* File thumbnails */}
|
||||
{chatFiles.length > 0 && (
|
||||
<div className='mb-2 flex flex-wrap gap-1.5'>
|
||||
{chatFiles.map((file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const previewUrl = isImage ? URL.createObjectURL(file.file) : null
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.includes('pdf'))
|
||||
return <FileText className='h-5 w-5 text-muted-foreground' />
|
||||
if (type.startsWith('image/'))
|
||||
return <Image className='h-5 w-5 text-muted-foreground' />
|
||||
if (type.includes('text') || type.includes('json'))
|
||||
return <FileText className='h-5 w-5 text-muted-foreground' />
|
||||
return <File className='h-5 w-5 text-muted-foreground' />
|
||||
}
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`group relative overflow-hidden rounded-md border border-border/50 bg-muted/20 ${
|
||||
previewUrl
|
||||
? 'h-16 w-16'
|
||||
: 'flex h-16 min-w-[120px] max-w-[200px] items-center gap-2 px-2'
|
||||
}`}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-background/50'>
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-foreground text-xs'>
|
||||
{file.name}
|
||||
</div>
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
setChatFiles(chatFiles.filter((f) => f.id !== file.id))
|
||||
}}
|
||||
className='absolute top-0.5 right-0.5 h-5 w-5 bg-gray-800/80 p-0 text-white opacity-0 transition-opacity hover:bg-gray-800/80 hover:text-white group-hover:opacity-100 dark:bg-black/70 dark:hover:bg-black/70 dark:hover:text-white'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ChatFileUpload
|
||||
files={chatFiles}
|
||||
onFilesChange={(files) => {
|
||||
setChatFiles(files)
|
||||
}}
|
||||
maxFiles={5}
|
||||
maxSize={10}
|
||||
disabled={!activeWorkflowId || isExecuting || isUploadingFiles}
|
||||
onError={(errors) => setUploadErrors(errors)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={chatMessage}
|
||||
onChange={(e) => {
|
||||
setChatMessage(e.target.value)
|
||||
setHistoryIndex(-1) // Reset history index when typing
|
||||
}}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
|
||||
className={`h-9 flex-1 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground shadow-xs focus-visible:ring-0 focus-visible:ring-offset-0 dark:border-[#414141] dark:bg-[var(--surface-elevated)] ${
|
||||
isDragOver
|
||||
? 'border-[var(--brand-primary-hover-hex)] bg-purple-50/50 dark:border-[var(--brand-primary-hover-hex)] dark:bg-purple-950/20'
|
||||
: ''
|
||||
}`}
|
||||
disabled={!activeWorkflowId || isExecuting || isUploadingFiles}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
size='icon'
|
||||
disabled={
|
||||
(!chatMessage.trim() && chatFiles.length === 0) ||
|
||||
!activeWorkflowId ||
|
||||
isExecuting ||
|
||||
isUploadingFiles
|
||||
}
|
||||
className='h-9 w-9 rounded-lg bg-[var(--brand-primary-hover-hex)] text-white shadow-[0_0_0_0_var(--brand-primary-hover-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
<ArrowUp className='h-4 w-4' />
|
||||
</Button>
|
||||
{/* Input row */}
|
||||
<div className='flex items-center gap-1'>
|
||||
{/* Attach button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => document.getElementById('chat-file-input')?.click()}
|
||||
disabled={
|
||||
!activeWorkflowId || isExecuting || isUploadingFiles || chatFiles.length >= 5
|
||||
}
|
||||
className='h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground'
|
||||
title='Attach files'
|
||||
>
|
||||
<Paperclip className='h-3 w-3' />
|
||||
</Button>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
id='chat-file-input'
|
||||
type='file'
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
|
||||
const newFiles: ChatFile[] = []
|
||||
const errors: string[] = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (chatFiles.length + newFiles.length >= 5) {
|
||||
errors.push('Maximum 5 files allowed')
|
||||
break
|
||||
}
|
||||
const file = files[i]
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
errors.push(`${file.name} is too large (max 10MB)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const isDuplicate = chatFiles.some(
|
||||
(existingFile) =>
|
||||
existingFile.name === file.name && existingFile.size === file.size
|
||||
)
|
||||
if (isDuplicate) {
|
||||
errors.push(`${file.name} already added`)
|
||||
continue
|
||||
}
|
||||
|
||||
newFiles.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
file,
|
||||
})
|
||||
}
|
||||
if (errors.length > 0) setUploadErrors(errors)
|
||||
if (newFiles.length > 0) {
|
||||
setChatFiles([...chatFiles, ...newFiles])
|
||||
setUploadErrors([]) // Clear errors when files are successfully added
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
className='hidden'
|
||||
disabled={!activeWorkflowId || isExecuting || isUploadingFiles}
|
||||
/>
|
||||
|
||||
{/* Text input */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={chatMessage}
|
||||
onChange={(e) => {
|
||||
setChatMessage(e.target.value)
|
||||
setHistoryIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
|
||||
className='h-8 flex-1 border-0 bg-transparent font-sans text-foreground text-sm shadow-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
disabled={!activeWorkflowId || isExecuting || isUploadingFiles}
|
||||
/>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
size='icon'
|
||||
disabled={
|
||||
(!chatMessage.trim() && chatFiles.length === 0) ||
|
||||
!activeWorkflowId ||
|
||||
isExecuting ||
|
||||
isUploadingFiles
|
||||
}
|
||||
className='h-6 w-6 shrink-0 rounded-full bg-[var(--brand-primary-hover-hex)] text-white shadow-[0_0_0_0_var(--brand-primary-hover-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
<ArrowUp className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useMemo } from 'react'
|
||||
import { File, FileText, Image as ImageIcon } from 'lucide-react'
|
||||
|
||||
interface ChatAttachment {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
dataUrl: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: {
|
||||
@@ -7,6 +16,7 @@ interface ChatMessageProps {
|
||||
timestamp: string | Date
|
||||
type: 'user' | 'workflow'
|
||||
isStreaming?: boolean
|
||||
attachments?: ChatAttachment[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +68,81 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
if (message.type === 'user') {
|
||||
return (
|
||||
<div className='w-full py-2'>
|
||||
{/* File attachments displayed above the message, completely separate from message box */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className='mb-1 flex justify-end'>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{message.attachments.map((attachment) => {
|
||||
const isImage = attachment.type.startsWith('image/')
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.includes('pdf'))
|
||||
return <FileText className='h-5 w-5 text-muted-foreground' />
|
||||
if (type.startsWith('image/'))
|
||||
return <ImageIcon className='h-5 w-5 text-muted-foreground' />
|
||||
if (type.includes('text') || type.includes('json'))
|
||||
return <FileText className='h-5 w-5 text-muted-foreground' />
|
||||
return <File className='h-5 w-5 text-muted-foreground' />
|
||||
}
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes || bytes === 0) return ''
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`relative overflow-hidden rounded-md border border-border/50 bg-muted/20 ${
|
||||
attachment.dataUrl?.trim() ? 'cursor-pointer' : ''
|
||||
} ${isImage ? 'h-16 w-16' : 'flex h-16 min-w-[120px] max-w-[200px] items-center gap-2 px-2'}`}
|
||||
onClick={(e) => {
|
||||
if (attachment.dataUrl?.trim()) {
|
||||
e.preventDefault()
|
||||
window.open(attachment.dataUrl, '_blank')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isImage && attachment.dataUrl ? (
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.name}
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-background/50'>
|
||||
{getFileIcon(attachment.type)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-foreground text-xs'>
|
||||
{attachment.name}
|
||||
</div>
|
||||
{attachment.size && (
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
{formatFileSize(attachment.size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<div className='max-w-[80%]'>
|
||||
<div className='rounded-[10px] bg-secondary px-3 py-2'>
|
||||
<div className='whitespace-pre-wrap break-words font-normal text-foreground text-sm leading-normal'>
|
||||
<WordWrap text={formattedContent} />
|
||||
</div>
|
||||
{/* Render text content if present and not just file count message */}
|
||||
{formattedContent && !formattedContent.startsWith('Uploaded') && (
|
||||
<div className='whitespace-pre-wrap break-words font-normal text-foreground text-sm leading-normal'>
|
||||
<WordWrap text={formattedContent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
<div className='whitespace-pre-wrap break-words text-foreground'>
|
||||
<WordWrap text={formattedContent} />
|
||||
{message.isStreaming && (
|
||||
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-primary' />
|
||||
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-gray-400 dark:bg-gray-300' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ResponseFormat as SharedResponseFormat } from '@/app/workspace/[workspa
|
||||
export interface JSONProperty {
|
||||
id: string
|
||||
key: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file'
|
||||
value?: any
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Plus, Trash } from 'lucide-react'
|
||||
import { ChevronDown, Paperclip, Plus, Trash } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
|
||||
interface Field {
|
||||
id: string
|
||||
name: string
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
||||
value?: string
|
||||
collapsed?: boolean
|
||||
}
|
||||
@@ -339,37 +339,46 @@ export function FieldFormat({
|
||||
onClick={() => updateField(field.id, 'type', 'string')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>Aa</span>
|
||||
<span className='mr-2 w-6 text-center font-mono'>Aa</span>
|
||||
<span>String</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'number')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>123</span>
|
||||
<span className='mr-2 w-6 text-center font-mono'>123</span>
|
||||
<span>Number</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'boolean')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>0/1</span>
|
||||
<span className='mr-2 w-6 text-center font-mono'>0/1</span>
|
||||
<span>Boolean</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'object')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>{'{}'}</span>
|
||||
<span className='mr-2 w-6 text-center font-mono'>{'{}'}</span>
|
||||
<span>Object</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'array')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<span className='mr-2 font-mono'>[]</span>
|
||||
<span className='mr-2 w-6 text-center font-mono'>[]</span>
|
||||
<span>Array</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'files')}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='mr-2 flex w-6 justify-center'>
|
||||
<Paperclip className='h-4 w-4' />
|
||||
</div>
|
||||
<span>Files</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ChatTriggerBlock: BlockConfig = {
|
||||
outputs: {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Conversation ID' },
|
||||
files: { type: 'array', description: 'Uploaded files' },
|
||||
files: { type: 'files', description: 'Uploaded files' },
|
||||
},
|
||||
triggers: {
|
||||
enabled: true,
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export type BlockIcon = (props: SVGProps<SVGSVGElement>) => JSX.Element
|
||||
export type ParamType = 'string' | 'number' | 'boolean' | 'json'
|
||||
export type PrimitiveValueType = 'string' | 'number' | 'boolean' | 'json' | 'array' | 'any'
|
||||
export type PrimitiveValueType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'json'
|
||||
| 'array'
|
||||
| 'files'
|
||||
| 'any'
|
||||
|
||||
export type BlockCategory = 'blocks' | 'tools' | 'triggers'
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ const getOutputTypeForPath = (
|
||||
const chatModeTypes: Record<string, string> = {
|
||||
input: 'string',
|
||||
conversationId: 'string',
|
||||
files: 'array',
|
||||
files: 'files',
|
||||
}
|
||||
return chatModeTypes[outputPath] || 'any'
|
||||
}
|
||||
@@ -336,10 +336,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
|
||||
const combinedAccessiblePrefixes = useMemo(() => {
|
||||
if (!rawAccessiblePrefixes) return new Set<string>()
|
||||
const normalized = new Set<string>(rawAccessiblePrefixes)
|
||||
normalized.add(normalizeBlockName(blockId))
|
||||
return normalized
|
||||
}, [rawAccessiblePrefixes, blockId])
|
||||
return new Set<string>(rawAccessiblePrefixes)
|
||||
}, [rawAccessiblePrefixes])
|
||||
|
||||
// Subscribe to live subblock values for the active workflow to react to input format changes
|
||||
const workflowSubBlockValues = useSubBlockStore((state) =>
|
||||
@@ -997,6 +995,33 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
|
||||
let processedTag = tag
|
||||
|
||||
// Check if this is a file property and add [0] automatically
|
||||
const fileProperties = ['url', 'name', 'size', 'type', 'key', 'uploadedAt', 'expiresAt']
|
||||
const parts = tag.split('.')
|
||||
if (parts.length >= 2 && fileProperties.includes(parts[parts.length - 1])) {
|
||||
const fieldName = parts[parts.length - 2]
|
||||
|
||||
if (blockGroup) {
|
||||
const block = useWorkflowStore.getState().blocks[blockGroup.blockId]
|
||||
const blockConfig = block ? (getBlock(block.type) ?? null) : null
|
||||
const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId)
|
||||
|
||||
const fieldType = getOutputTypeForPath(
|
||||
block,
|
||||
blockConfig,
|
||||
blockGroup.blockId,
|
||||
fieldName,
|
||||
mergedSubBlocks
|
||||
)
|
||||
|
||||
if (fieldType === 'files') {
|
||||
const blockAndField = parts.slice(0, -1).join('.')
|
||||
const property = parts[parts.length - 1]
|
||||
processedTag = `${blockAndField}[0].${property}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tag.startsWith(TAG_PREFIXES.VARIABLE)) {
|
||||
const variableName = tag.substring(TAG_PREFIXES.VARIABLE.length)
|
||||
const variableObj = workflowVariables.find(
|
||||
|
||||
@@ -9,7 +9,7 @@ const logger = createLogger('ResponseBlockHandler')
|
||||
interface JSONProperty {
|
||||
id: string
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
||||
value: any
|
||||
collapsed?: boolean
|
||||
}
|
||||
@@ -140,6 +140,9 @@ export class ResponseBlockHandler implements BlockHandler {
|
||||
return this.convertNumberValue(prop.value)
|
||||
case 'boolean':
|
||||
return this.convertBooleanValue(prop.value)
|
||||
case 'files':
|
||||
// File values should be passed through as-is (UserFile objects)
|
||||
return prop.value
|
||||
default:
|
||||
return prop.value
|
||||
}
|
||||
|
||||
@@ -126,8 +126,17 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
finalOutput: BlockOutputData
|
||||
traceSpans?: TraceSpan[]
|
||||
workflowInput?: any
|
||||
}): Promise<WorkflowExecutionLog> {
|
||||
const { executionId, endedAt, totalDurationMs, costSummary, finalOutput, traceSpans } = params
|
||||
const {
|
||||
executionId,
|
||||
endedAt,
|
||||
totalDurationMs,
|
||||
costSummary,
|
||||
finalOutput,
|
||||
traceSpans,
|
||||
workflowInput,
|
||||
} = params
|
||||
|
||||
logger.debug(`Completing workflow execution ${executionId}`)
|
||||
|
||||
@@ -145,8 +154,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
|
||||
const level = hasErrors ? 'error' : 'info'
|
||||
|
||||
// Extract files from trace spans and final output
|
||||
const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput)
|
||||
// Extract files from trace spans, final output, and workflow input
|
||||
const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput)
|
||||
|
||||
const [updatedLog] = await db
|
||||
.update(workflowExecutionLogs)
|
||||
@@ -456,9 +465,13 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file references from execution trace spans and final output
|
||||
* Extract file references from execution trace spans, final output, and workflow input
|
||||
*/
|
||||
private extractFilesFromExecution(traceSpans?: any[], finalOutput?: any): any[] {
|
||||
private extractFilesFromExecution(
|
||||
traceSpans?: any[],
|
||||
finalOutput?: any,
|
||||
workflowInput?: any
|
||||
): any[] {
|
||||
const files: any[] = []
|
||||
const seenFileIds = new Set<string>()
|
||||
|
||||
@@ -558,6 +571,15 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
extractFilesFromObject(finalOutput, 'final_output')
|
||||
}
|
||||
|
||||
// Extract files from workflow input
|
||||
if (workflowInput) {
|
||||
extractFilesFromObject(workflowInput, 'workflow_input')
|
||||
}
|
||||
|
||||
logger.debug(`Extracted ${files.length} file(s) from execution`, {
|
||||
fileNames: files.map((f) => f.name),
|
||||
})
|
||||
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface SessionCompleteParams {
|
||||
totalDurationMs?: number
|
||||
finalOutput?: any
|
||||
traceSpans?: any[]
|
||||
workflowInput?: any
|
||||
}
|
||||
|
||||
export interface SessionErrorCompleteParams {
|
||||
@@ -106,7 +107,7 @@ export class LoggingSession {
|
||||
}
|
||||
|
||||
async complete(params: SessionCompleteParams = {}): Promise<void> {
|
||||
const { endedAt, totalDurationMs, finalOutput, traceSpans } = params
|
||||
const { endedAt, totalDurationMs, finalOutput, traceSpans, workflowInput } = params
|
||||
|
||||
try {
|
||||
const costSummary = calculateCostSummary(traceSpans || [])
|
||||
@@ -118,6 +119,7 @@ export class LoggingSession {
|
||||
costSummary,
|
||||
finalOutput: finalOutput || {},
|
||||
traceSpans: traceSpans || [],
|
||||
workflowInput,
|
||||
})
|
||||
|
||||
if (this.requestId) {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function getBlockOutputs(
|
||||
return {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Conversation ID' },
|
||||
files: { type: 'array', description: 'Uploaded files' },
|
||||
files: { type: 'files', description: 'Uploaded files' },
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -136,7 +136,20 @@ export function getBlockOutputPaths(
|
||||
|
||||
// If value has 'type' property, it's a leaf node (output definition)
|
||||
if (value && typeof value === 'object' && 'type' in value) {
|
||||
paths.push(path)
|
||||
// Special handling for 'files' type - expand to show array element properties
|
||||
if (value.type === 'files') {
|
||||
// Show properties without [0] for cleaner display
|
||||
// The tag dropdown will add [0] automatically when inserting
|
||||
paths.push(`${path}.url`)
|
||||
paths.push(`${path}.name`)
|
||||
paths.push(`${path}.size`)
|
||||
paths.push(`${path}.type`)
|
||||
paths.push(`${path}.key`)
|
||||
paths.push(`${path}.uploadedAt`)
|
||||
paths.push(`${path}.expiresAt`)
|
||||
} else {
|
||||
paths.push(path)
|
||||
}
|
||||
}
|
||||
// If value is an object without 'type', recurse into it
|
||||
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
@@ -164,8 +177,33 @@ export function getBlockOutputType(
|
||||
): string {
|
||||
const outputs = getBlockOutputs(blockType, subBlocks, triggerMode)
|
||||
|
||||
// Navigate through nested path
|
||||
const pathParts = outputPath.split('.')
|
||||
const arrayIndexRegex = /\[(\d+)\]/g
|
||||
const cleanPath = outputPath.replace(arrayIndexRegex, '')
|
||||
const pathParts = cleanPath.split('.').filter(Boolean)
|
||||
|
||||
const filePropertyTypes: Record<string, string> = {
|
||||
url: 'string',
|
||||
name: 'string',
|
||||
size: 'number',
|
||||
type: 'string',
|
||||
key: 'string',
|
||||
uploadedAt: 'string',
|
||||
expiresAt: 'string',
|
||||
}
|
||||
|
||||
const lastPart = pathParts[pathParts.length - 1]
|
||||
if (lastPart && filePropertyTypes[lastPart]) {
|
||||
const parentPath = pathParts.slice(0, -1).join('.')
|
||||
let current: any = outputs
|
||||
for (const part of pathParts.slice(0, -1)) {
|
||||
if (!current || typeof current !== 'object') break
|
||||
current = current[part]
|
||||
}
|
||||
if (current && typeof current === 'object' && 'type' in current && current.type === 'files') {
|
||||
return filePropertyTypes[lastPart]
|
||||
}
|
||||
}
|
||||
|
||||
let current: any = outputs
|
||||
|
||||
for (const part of pathParts) {
|
||||
|
||||
@@ -1,53 +1,173 @@
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey, userStats, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
apiKey,
|
||||
permissions,
|
||||
userStats,
|
||||
workflow as workflowTable,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import type { PermissionType } from '@/lib/permissions/utils'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
const WORKFLOW_BASE_SELECTION = {
|
||||
id: workflowTable.id,
|
||||
userId: workflowTable.userId,
|
||||
workspaceId: workflowTable.workspaceId,
|
||||
folderId: workflowTable.folderId,
|
||||
name: workflowTable.name,
|
||||
description: workflowTable.description,
|
||||
color: workflowTable.color,
|
||||
lastSynced: workflowTable.lastSynced,
|
||||
createdAt: workflowTable.createdAt,
|
||||
updatedAt: workflowTable.updatedAt,
|
||||
isDeployed: workflowTable.isDeployed,
|
||||
deployedState: workflowTable.deployedState,
|
||||
deployedAt: workflowTable.deployedAt,
|
||||
pinnedApiKeyId: workflowTable.pinnedApiKeyId,
|
||||
collaborators: workflowTable.collaborators,
|
||||
runCount: workflowTable.runCount,
|
||||
lastRunAt: workflowTable.lastRunAt,
|
||||
variables: workflowTable.variables,
|
||||
isPublished: workflowTable.isPublished,
|
||||
marketplaceData: workflowTable.marketplaceData,
|
||||
pinnedApiKeyKey: apiKey.key,
|
||||
pinnedApiKeyName: apiKey.name,
|
||||
pinnedApiKeyType: apiKey.type,
|
||||
pinnedApiKeyWorkspaceId: apiKey.workspaceId,
|
||||
}
|
||||
|
||||
type WorkflowSelection = InferSelectModel<typeof workflowTable>
|
||||
type ApiKeySelection = InferSelectModel<typeof apiKey>
|
||||
|
||||
type WorkflowRow = WorkflowSelection & {
|
||||
pinnedApiKeyKey: ApiKeySelection['key'] | null
|
||||
pinnedApiKeyName: ApiKeySelection['name'] | null
|
||||
pinnedApiKeyType: ApiKeySelection['type'] | null
|
||||
pinnedApiKeyWorkspaceId: ApiKeySelection['workspaceId'] | null
|
||||
}
|
||||
|
||||
type WorkflowWithPinnedKey = WorkflowSelection & {
|
||||
pinnedApiKey: Pick<ApiKeySelection, 'id' | 'name' | 'key' | 'type' | 'workspaceId'> | null
|
||||
}
|
||||
|
||||
function mapWorkflowRow(row: WorkflowRow | undefined): WorkflowWithPinnedKey | undefined {
|
||||
if (!row) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const {
|
||||
pinnedApiKeyKey,
|
||||
pinnedApiKeyName,
|
||||
pinnedApiKeyType,
|
||||
pinnedApiKeyWorkspaceId,
|
||||
...workflowWithoutDerived
|
||||
} = row
|
||||
|
||||
const pinnedApiKey =
|
||||
workflowWithoutDerived.pinnedApiKeyId && pinnedApiKeyKey && pinnedApiKeyName && pinnedApiKeyType
|
||||
? {
|
||||
id: workflowWithoutDerived.pinnedApiKeyId,
|
||||
name: pinnedApiKeyName,
|
||||
key: pinnedApiKeyKey,
|
||||
type: pinnedApiKeyType,
|
||||
workspaceId: pinnedApiKeyWorkspaceId,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
...workflowWithoutDerived,
|
||||
pinnedApiKey,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkflowById(id: string) {
|
||||
const workflows = await db
|
||||
.select({
|
||||
id: workflowTable.id,
|
||||
userId: workflowTable.userId,
|
||||
workspaceId: workflowTable.workspaceId,
|
||||
folderId: workflowTable.folderId,
|
||||
name: workflowTable.name,
|
||||
description: workflowTable.description,
|
||||
color: workflowTable.color,
|
||||
lastSynced: workflowTable.lastSynced,
|
||||
createdAt: workflowTable.createdAt,
|
||||
updatedAt: workflowTable.updatedAt,
|
||||
isDeployed: workflowTable.isDeployed,
|
||||
deployedState: workflowTable.deployedState,
|
||||
deployedAt: workflowTable.deployedAt,
|
||||
pinnedApiKeyId: workflowTable.pinnedApiKeyId,
|
||||
collaborators: workflowTable.collaborators,
|
||||
runCount: workflowTable.runCount,
|
||||
lastRunAt: workflowTable.lastRunAt,
|
||||
variables: workflowTable.variables,
|
||||
isPublished: workflowTable.isPublished,
|
||||
marketplaceData: workflowTable.marketplaceData,
|
||||
pinnedApiKey: {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: apiKey.key,
|
||||
type: apiKey.type,
|
||||
workspaceId: apiKey.workspaceId,
|
||||
},
|
||||
})
|
||||
const rows = await db
|
||||
.select(WORKFLOW_BASE_SELECTION)
|
||||
.from(workflowTable)
|
||||
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
|
||||
.where(eq(workflowTable.id, id))
|
||||
.limit(1)
|
||||
|
||||
return workflows[0]
|
||||
return mapWorkflowRow(rows[0] as WorkflowRow | undefined)
|
||||
}
|
||||
|
||||
type WorkflowRecord = ReturnType<typeof getWorkflowById> extends Promise<infer R>
|
||||
? NonNullable<R>
|
||||
: never
|
||||
|
||||
export interface WorkflowAccessContext {
|
||||
workflow: WorkflowRecord
|
||||
workspaceOwnerId: string | null
|
||||
workspacePermission: PermissionType | null
|
||||
isOwner: boolean
|
||||
isWorkspaceOwner: boolean
|
||||
}
|
||||
|
||||
export async function getWorkflowAccessContext(
|
||||
workflowId: string,
|
||||
userId?: string
|
||||
): Promise<WorkflowAccessContext | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
...WORKFLOW_BASE_SELECTION,
|
||||
workspaceOwnerId: workspace.ownerId,
|
||||
workspacePermission: permissions.permissionType,
|
||||
})
|
||||
.from(workflowTable)
|
||||
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
|
||||
.leftJoin(workspace, eq(workspace.id, workflowTable.workspaceId))
|
||||
.leftJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflowTable.workspaceId),
|
||||
userId ? eq(permissions.userId, userId) : eq(permissions.userId, '' as unknown as string)
|
||||
)
|
||||
)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0] as
|
||||
| (WorkflowRow & {
|
||||
workspaceOwnerId: string | null
|
||||
workspacePermission: PermissionType | null
|
||||
})
|
||||
| undefined
|
||||
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workflow = mapWorkflowRow(row as WorkflowRow)
|
||||
|
||||
if (!workflow) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolvedWorkspaceOwner = row.workspaceOwnerId ?? null
|
||||
const resolvedWorkspacePermission = row.workspacePermission ?? null
|
||||
|
||||
const resolvedUserId = userId ?? null
|
||||
|
||||
const isOwner = resolvedUserId ? workflow.userId === resolvedUserId : false
|
||||
const isWorkspaceOwner = resolvedUserId ? resolvedWorkspaceOwner === resolvedUserId : false
|
||||
|
||||
return {
|
||||
workflow,
|
||||
workspaceOwnerId: resolvedWorkspaceOwner,
|
||||
workspacePermission: resolvedWorkspacePermission,
|
||||
isOwner,
|
||||
isWorkspaceOwner,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWorkflowRunCounts(workflowId: string, runs = 1) {
|
||||
@@ -421,8 +541,8 @@ export async function validateWorkflowPermissions(
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = await getWorkflowById(workflowId)
|
||||
if (!workflow) {
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, session.user.id)
|
||||
if (!accessContext) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return {
|
||||
error: { message: 'Workflow not found', status: 404 },
|
||||
@@ -431,9 +551,31 @@ export async function validateWorkflowPermissions(
|
||||
}
|
||||
}
|
||||
|
||||
const { workflow, workspacePermission, isOwner } = accessContext
|
||||
|
||||
if (isOwner) {
|
||||
return {
|
||||
error: null,
|
||||
session,
|
||||
workflow,
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.workspaceId) {
|
||||
const hasAccess = await hasWorkspaceAdminAccess(session.user.id, workflow.workspaceId)
|
||||
if (!hasAccess) {
|
||||
let hasPermission = false
|
||||
|
||||
if (action === 'read') {
|
||||
// Any workspace permission allows read
|
||||
hasPermission = workspacePermission !== null
|
||||
} else if (action === 'write') {
|
||||
// Write or admin permission allows write
|
||||
hasPermission = workspacePermission === 'write' || workspacePermission === 'admin'
|
||||
} else if (action === 'admin') {
|
||||
// Only admin permission allows admin actions
|
||||
hasPermission = workspacePermission === 'admin'
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} unauthorized to ${action} workflow ${workflowId} in workspace ${workflow.workspaceId}`
|
||||
)
|
||||
@@ -444,15 +586,13 @@ export async function validateWorkflowPermissions(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (workflow.userId !== session.user.id) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} unauthorized to ${action} workflow ${workflowId} owned by ${workflow.userId}`
|
||||
)
|
||||
return {
|
||||
error: { message: `Unauthorized: Access denied to ${action} this workflow`, status: 403 },
|
||||
session: null,
|
||||
workflow: null,
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} unauthorized to ${action} workflow ${workflowId} owned by ${workflow.userId}`
|
||||
)
|
||||
return {
|
||||
error: { message: `Unauthorized: Access denied to ${action} this workflow`, status: 403 },
|
||||
session: null,
|
||||
workflow: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export interface ChatAttachment {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
dataUrl: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string | any
|
||||
@@ -6,6 +14,7 @@ export interface ChatMessage {
|
||||
timestamp: string
|
||||
blockId?: string
|
||||
isStreaming?: boolean
|
||||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
export interface OutputConfig {
|
||||
|
||||
@@ -57,7 +57,7 @@ result = client.execute_workflow(
|
||||
|
||||
**Parameters:**
|
||||
- `workflow_id` (str): The ID of the workflow to execute
|
||||
- `input_data` (dict, optional): Input data to pass to the workflow
|
||||
- `input_data` (dict, optional): Input data to pass to the workflow. File objects are automatically converted to base64.
|
||||
- `timeout` (float): Timeout in seconds (default: 30.0)
|
||||
|
||||
**Returns:** `WorkflowExecutionResult`
|
||||
@@ -265,6 +265,57 @@ client = SimStudioClient(
|
||||
)
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
File objects are automatically detected and converted to base64 format. Include them in your input under the field name matching your workflow's API trigger input format:
|
||||
|
||||
The SDK converts file objects to this format:
|
||||
```python
|
||||
{
|
||||
'type': 'file',
|
||||
'data': 'data:mime/type;base64,base64data',
|
||||
'name': 'filename',
|
||||
'mime': 'mime/type'
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can manually provide files using the URL format:
|
||||
```python
|
||||
{
|
||||
'type': 'url',
|
||||
'data': 'https://example.com/file.pdf',
|
||||
'name': 'file.pdf',
|
||||
'mime': 'application/pdf'
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
from simstudio import SimStudioClient
|
||||
import os
|
||||
|
||||
client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
|
||||
# Upload a single file - include it under the field name from your API trigger
|
||||
with open('document.pdf', 'rb') as f:
|
||||
result = client.execute_workflow(
|
||||
'workflow-id',
|
||||
input_data={
|
||||
'documents': [f], # Must match your workflow's "files" field name
|
||||
'instructions': 'Analyze this document'
|
||||
}
|
||||
)
|
||||
|
||||
# Upload multiple files
|
||||
with open('doc1.pdf', 'rb') as f1, open('doc2.pdf', 'rb') as f2:
|
||||
result = client.execute_workflow(
|
||||
'workflow-id',
|
||||
input_data={
|
||||
'attachments': [f1, f2], # Must match your workflow's "files" field name
|
||||
'query': 'Compare these documents'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Batch Workflow Execution
|
||||
|
||||
```python
|
||||
@@ -276,14 +327,14 @@ client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
|
||||
def execute_workflows_batch(workflow_data_pairs):
|
||||
"""Execute multiple workflows with different input data."""
|
||||
results = []
|
||||
|
||||
|
||||
for workflow_id, input_data in workflow_data_pairs:
|
||||
try:
|
||||
# Validate workflow before execution
|
||||
if not client.validate_workflow(workflow_id):
|
||||
print(f"Skipping {workflow_id}: not deployed")
|
||||
continue
|
||||
|
||||
|
||||
result = client.execute_workflow(workflow_id, input_data)
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
@@ -291,14 +342,14 @@ def execute_workflows_batch(workflow_data_pairs):
|
||||
"output": result.output,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
|
||||
except Exception as error:
|
||||
results.append({
|
||||
"workflow_id": workflow_id,
|
||||
"success": False,
|
||||
"error": str(error)
|
||||
})
|
||||
|
||||
|
||||
return results
|
||||
|
||||
# Example usage
|
||||
|
||||
55
packages/python-sdk/examples/file_upload.py
Normal file
55
packages/python-sdk/examples/file_upload.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Example: Upload files with workflow execution
|
||||
|
||||
This example demonstrates how to upload files when executing a workflow.
|
||||
Files are automatically detected and converted to base64 format.
|
||||
"""
|
||||
|
||||
from simstudio import SimStudioClient
|
||||
import os
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize the client
|
||||
api_key = os.getenv('SIM_API_KEY')
|
||||
if not api_key:
|
||||
raise ValueError('SIM_API_KEY environment variable is required')
|
||||
|
||||
client = SimStudioClient(api_key=api_key)
|
||||
|
||||
# Example 1: Upload a single file
|
||||
# Include file under the field name from your workflow's API trigger input format
|
||||
print("Example 1: Upload a single file")
|
||||
with open('document.pdf', 'rb') as f:
|
||||
result = client.execute_workflow(
|
||||
workflow_id='your-workflow-id',
|
||||
input_data={
|
||||
'documents': [f], # Field name must match your API trigger's file input field
|
||||
'instructions': 'Analyze this document'
|
||||
}
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print(f"Success! Output: {result.output}")
|
||||
else:
|
||||
print(f"Failed: {result.error}")
|
||||
|
||||
# Example 2: Upload multiple files
|
||||
print("\nExample 2: Upload multiple files")
|
||||
with open('document1.pdf', 'rb') as f1, open('document2.pdf', 'rb') as f2:
|
||||
result = client.execute_workflow(
|
||||
workflow_id='your-workflow-id',
|
||||
input_data={
|
||||
'attachments': [f1, f2], # Field name must match your API trigger's file input field
|
||||
'query': 'Compare these documents'
|
||||
}
|
||||
)
|
||||
|
||||
if result.success:
|
||||
print(f"Success! Output: {result.output}")
|
||||
else:
|
||||
print(f"Failed: {result.error}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
import random
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
@@ -109,6 +110,53 @@ class SimStudioClient:
|
||||
})
|
||||
self._rate_limit_info: Optional[RateLimitInfo] = None
|
||||
|
||||
def _convert_files_to_base64(self, value: Any) -> Any:
|
||||
"""
|
||||
Convert file objects in input to API format (base64).
|
||||
Recursively processes nested dicts and lists.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
|
||||
# Check if this is a file-like object
|
||||
if hasattr(value, 'read') and callable(value.read):
|
||||
# Save current position if seekable
|
||||
initial_pos = value.tell() if hasattr(value, 'tell') else None
|
||||
|
||||
# Read file bytes
|
||||
file_bytes = value.read()
|
||||
|
||||
# Restore position if seekable
|
||||
if initial_pos is not None and hasattr(value, 'seek'):
|
||||
value.seek(initial_pos)
|
||||
|
||||
# Encode to base64
|
||||
base64_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
|
||||
# Get file metadata
|
||||
filename = getattr(value, 'name', 'file')
|
||||
if isinstance(filename, str):
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
content_type = getattr(value, 'content_type', 'application/octet-stream')
|
||||
|
||||
return {
|
||||
'type': 'file',
|
||||
'data': f'data:{content_type};base64,{base64_data}',
|
||||
'name': filename,
|
||||
'mime': content_type
|
||||
}
|
||||
|
||||
# Recursively process lists
|
||||
if isinstance(value, list):
|
||||
return [self._convert_files_to_base64(item) for item in value]
|
||||
|
||||
# Recursively process dicts
|
||||
if isinstance(value, dict):
|
||||
return {k: self._convert_files_to_base64(v) for k, v in value.items()}
|
||||
|
||||
return value
|
||||
|
||||
def execute_workflow(
|
||||
self,
|
||||
workflow_id: str,
|
||||
@@ -122,9 +170,11 @@ class SimStudioClient:
|
||||
Execute a workflow with optional input data.
|
||||
If async_execution is True, returns immediately with a task ID.
|
||||
|
||||
File objects in input_data will be automatically detected and converted to base64.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input_data: Input data to pass to the workflow
|
||||
input_data: Input data to pass to the workflow (can include file-like objects)
|
||||
timeout: Timeout in seconds (default: 30.0)
|
||||
stream: Enable streaming responses (default: None)
|
||||
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
|
||||
@@ -138,19 +188,23 @@ class SimStudioClient:
|
||||
"""
|
||||
url = f"{self.base_url}/api/workflows/{workflow_id}/execute"
|
||||
|
||||
# Build request body - spread input at root level, then add API control parameters
|
||||
body = input_data.copy() if input_data is not None else {}
|
||||
if stream is not None:
|
||||
body['stream'] = stream
|
||||
if selected_outputs is not None:
|
||||
body['selectedOutputs'] = selected_outputs
|
||||
|
||||
# Build headers - async execution uses X-Execution-Mode header
|
||||
headers = self._session.headers.copy()
|
||||
if async_execution:
|
||||
headers['X-Execution-Mode'] = 'async'
|
||||
|
||||
try:
|
||||
# Build JSON body - spread input at root level, then add API control parameters
|
||||
body = input_data.copy() if input_data is not None else {}
|
||||
|
||||
# Convert any file objects in the input to base64 format
|
||||
body = self._convert_files_to_base64(body)
|
||||
|
||||
if stream is not None:
|
||||
body['stream'] = stream
|
||||
if selected_outputs is not None:
|
||||
body['selectedOutputs'] = selected_outputs
|
||||
|
||||
response = self._session.post(
|
||||
url,
|
||||
json=body,
|
||||
@@ -281,7 +335,7 @@ class SimStudioClient:
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input_data: Input data to pass to the workflow
|
||||
input_data: Input data to pass to the workflow (can include file-like objects)
|
||||
timeout: Timeout for the initial request in seconds
|
||||
stream: Enable streaming responses (default: None)
|
||||
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
|
||||
@@ -373,7 +427,7 @@ class SimStudioClient:
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to execute
|
||||
input_data: Input data to pass to the workflow
|
||||
input_data: Input data to pass to the workflow (can include file-like objects)
|
||||
timeout: Timeout in seconds
|
||||
stream: Enable streaming responses
|
||||
selected_outputs: Block outputs to stream
|
||||
|
||||
@@ -61,7 +61,7 @@ const result = await client.executeWorkflow('workflow-id', {
|
||||
**Parameters:**
|
||||
- `workflowId` (string): The ID of the workflow to execute
|
||||
- `options` (ExecutionOptions, optional):
|
||||
- `input` (any): Input data to pass to the workflow
|
||||
- `input` (any): Input data to pass to the workflow. File objects are automatically converted to base64.
|
||||
- `timeout` (number): Timeout in milliseconds (default: 30000)
|
||||
|
||||
**Returns:** `Promise<WorkflowExecutionResult>`
|
||||
@@ -261,6 +261,64 @@ const client = new SimStudioClient({
|
||||
});
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
File objects are automatically detected and converted to base64 format. Include them in your input under the field name matching your workflow's API trigger input format:
|
||||
|
||||
The SDK converts File objects to this format:
|
||||
```typescript
|
||||
{
|
||||
type: 'file',
|
||||
data: 'data:mime/type;base64,base64data',
|
||||
name: 'filename',
|
||||
mime: 'mime/type'
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can manually provide files using the URL format:
|
||||
```typescript
|
||||
{
|
||||
type: 'url',
|
||||
data: 'https://example.com/file.pdf',
|
||||
name: 'file.pdf',
|
||||
mime: 'application/pdf'
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { SimStudioClient } from 'simstudio-ts-sdk';
|
||||
import fs from 'fs';
|
||||
|
||||
const client = new SimStudioClient({
|
||||
apiKey: process.env.SIM_API_KEY!
|
||||
});
|
||||
|
||||
// Node.js: Read file and create File object
|
||||
const fileBuffer = fs.readFileSync('./document.pdf');
|
||||
const file = new File([fileBuffer], 'document.pdf', { type: 'application/pdf' });
|
||||
|
||||
// Include files under the field name from your API trigger's input format
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: {
|
||||
documents: [file], // Field name must match your API trigger's file input field
|
||||
instructions: 'Process this document'
|
||||
}
|
||||
});
|
||||
|
||||
// Browser: From file input
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
|
||||
const result = await client.executeWorkflow('workflow-id', {
|
||||
input: {
|
||||
attachments: files, // Field name must match your API trigger's file input field
|
||||
query: 'Analyze these files'
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Getting Your API Key
|
||||
|
||||
1. Log in to your [Sim](https://sim.ai) account
|
||||
|
||||
@@ -108,6 +108,55 @@ export class SimStudioClient {
|
||||
* Execute a workflow with optional input data
|
||||
* If async is true, returns immediately with a task ID
|
||||
*/
|
||||
/**
|
||||
* Convert File objects in input to API format (base64)
|
||||
* Recursively processes nested objects and arrays
|
||||
*/
|
||||
private async convertFilesToBase64(
|
||||
value: any,
|
||||
visited: WeakSet<object> = new WeakSet()
|
||||
): Promise<any> {
|
||||
if (value instanceof File) {
|
||||
const arrayBuffer = await value.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
const base64 = buffer.toString('base64')
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
data: `data:${value.type || 'application/octet-stream'};base64,${base64}`,
|
||||
name: value.name,
|
||||
mime: value.type || 'application/octet-stream',
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (visited.has(value)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
visited.add(value)
|
||||
const result = await Promise.all(
|
||||
value.map((item) => this.convertFilesToBase64(item, visited))
|
||||
)
|
||||
visited.delete(value)
|
||||
return result
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
if (visited.has(value)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
visited.add(value)
|
||||
const converted: any = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
converted[key] = await this.convertFilesToBase64(val, visited)
|
||||
}
|
||||
visited.delete(value)
|
||||
return converted
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
async executeWorkflow(
|
||||
workflowId: string,
|
||||
options: ExecutionOptions = {}
|
||||
@@ -121,15 +170,6 @@ export class SimStudioClient {
|
||||
setTimeout(() => reject(new Error('TIMEOUT')), timeout)
|
||||
})
|
||||
|
||||
// Build request body - spread input at root level, then add API control parameters
|
||||
const body: any = input !== undefined ? { ...input } : {}
|
||||
if (stream !== undefined) {
|
||||
body.stream = stream
|
||||
}
|
||||
if (selectedOutputs !== undefined) {
|
||||
body.selectedOutputs = selectedOutputs
|
||||
}
|
||||
|
||||
// Build headers - async execution uses X-Execution-Mode header
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -139,10 +179,23 @@ export class SimStudioClient {
|
||||
headers['X-Execution-Mode'] = 'async'
|
||||
}
|
||||
|
||||
// Build JSON body - spread input at root level, then add API control parameters
|
||||
let jsonBody: any = input !== undefined ? { ...input } : {}
|
||||
|
||||
// Convert any File objects in the input to base64 format
|
||||
jsonBody = await this.convertFilesToBase64(jsonBody)
|
||||
|
||||
if (stream !== undefined) {
|
||||
jsonBody.stream = stream
|
||||
}
|
||||
if (selectedOutputs !== undefined) {
|
||||
jsonBody.selectedOutputs = selectedOutputs
|
||||
}
|
||||
|
||||
const fetchPromise = fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(jsonBody),
|
||||
})
|
||||
|
||||
const response = await Promise.race([fetchPromise, timeoutPromise])
|
||||
|
||||
Reference in New Issue
Block a user