diff --git a/apps/docs/content/docs/en/tools/azure_devops.mdx b/apps/docs/content/docs/en/tools/azure_devops.mdx index 38b3bfb69d8..e0add5348ae 100644 --- a/apps/docs/content/docs/en/tools/azure_devops.mdx +++ b/apps/docs/content/docs/en/tools/azure_devops.mdx @@ -280,7 +280,7 @@ Get the execution timeline for an Azure DevOps build — every stage, job, and t | ↳ `warningCount` | number | Number of warnings | | ↳ `startTime` | string | ISO 8601 start timestamp | | ↳ `finishTime` | string | ISO 8601 finish timestamp | -| ↳ `failedRecords` | array | Subset of records where result === "failed" — use logId to fetch logs | +| ↳ `failedRecords` | array | Subset of records where result is failed, partiallySucceeded, or succeededWithIssues — use logId to fetch logs | | ↳ `id` | string | Record GUID | | ↳ `name` | string | Step name | | ↳ `type` | string | Stage \| Phase \| Job \| Task | @@ -333,7 +333,8 @@ Execute a WIQL query to search for work items in Azure DevOps and return full fi | --------- | ---- | ----------- | | `content` | string | Human-readable summary of matching work items | | `metadata` | object | Work items metadata | -| ↳ `count` | number | Number of work items returned | +| ↳ `count` | number | Number of work items returned \(after hydration\) | +| ↳ `totalMatched` | number | Total number of work items matched by the WIQL query before hydration | | ↳ `workItems` | array | Array of work item details | | ↳ `id` | number | Work item ID | | ↳ `title` | string | Work item title | @@ -372,7 +373,7 @@ Fetch full details of a single work item by ID from Azure DevOps, including titl ### `azure_devops_get_work_items_batch` -Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. +Fetch full details for multiple work items by ID from Azure DevOps. Pass comma-separated IDs (e.g. #### Input @@ -380,7 +381,7 @@ Fetch full details for multiple work items by ID from Azure DevOps in a single c | --------- | ---- | -------- | ----------- | | `organization` | string | Yes | Azure DevOps organization name | | `project` | string | Yes | Azure DevOps project name | -| `ids` | string | Yes | Comma-separated work item IDs to fetch \(e.g. "123,456,789"\). Maximum 200 IDs. | +| `ids` | string | Yes | Comma-separated work item IDs to fetch \(e.g. "123,456,789"\). Lists longer than 200 IDs are chunked automatically. | #### Output @@ -389,6 +390,7 @@ Fetch full details for multiple work items by ID from Azure DevOps in a single c | `content` | string | Human-readable summary of the fetched work items | | `metadata` | object | Work items metadata | | ↳ `count` | number | Number of work items returned | +| ↳ `totalRequested` | number | Total number of IDs requested \(across all chunks\) | | ↳ `workItems` | array | Array of work item details | | ↳ `id` | number | Work item ID | | ↳ `title` | string | Work item title | diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 075f99a2954..f259653fdaa 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -195,7 +195,6 @@ "upstash", "vercel", "video_generator", - "vision", "wealthbox", "webflow", "whatsapp", diff --git a/apps/docs/content/docs/en/tools/vision.mdx b/apps/docs/content/docs/en/tools/vision.mdx deleted file mode 100644 index 1ab0cee507c..00000000000 --- a/apps/docs/content/docs/en/tools/vision.mdx +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: Vision -description: Analyze images with vision models ---- - -import { BlockInfoCard } from "@/components/ui/block-info-card" - - - -{/* MANUAL-CONTENT-START:intro */} -Vision is a tool that allows you to analyze images with vision models. - -With Vision, you can: - -- **Analyze images**: Analyze images with vision models -- **Extract text**: Extract text from images -- **Identify objects**: Identify objects in images -- **Describe images**: Describe images in detail -- **Generate images**: Generate images from text - -In Sim, the Vision integration enables your agents to analyze images with vision models as part of their workflows. This allows for powerful automation scenarios that require analyzing images with vision models. Your agents can analyze images with vision models, extract text from images, identify objects in images, describe images in detail, and generate images from text. This integration bridges the gap between your AI workflows and your image analysis needs, enabling more sophisticated and image-centric automations. By connecting Sim with Vision, you can create agents that stay current with the latest information, provide more accurate responses, and deliver more value to users - all without requiring manual intervention or custom code. -{/* MANUAL-CONTENT-END */} - - -## Usage Instructions - -Integrate Vision into the workflow. Can analyze images with vision models. - - - -## Tools - -### `vision_tool` - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | API key for the selected model provider | -| `imageUrl` | string | No | Publicly accessible image URL | -| `imageFile` | file | No | Image file to analyze | -| `model` | string | No | Vision model to use \(gpt-4o, claude-3-opus-20240229, etc\) | -| `prompt` | string | No | Custom prompt for image analysis | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `content` | string | The analyzed content and description of the image | -| `model` | string | The vision model that was used for analysis | -| `tokens` | number | Total tokens used for the analysis | -| `usage` | object | Detailed token usage breakdown | -| ↳ `input_tokens` | number | Tokens used for input processing | -| ↳ `output_tokens` | number | Tokens used for response generation | -| ↳ `total_tokens` | number | Total tokens consumed | - - diff --git a/apps/docs/content/docs/en/triggers/azure_devops.mdx b/apps/docs/content/docs/en/triggers/azure_devops.mdx index d22c9ae8257..d9d4b9b0128 100644 --- a/apps/docs/content/docs/en/triggers/azure_devops.mdx +++ b/apps/docs/content/docs/en/triggers/azure_devops.mdx @@ -31,7 +31,7 @@ Trigger workflow when an Azure DevOps build fails, is canceled, or partially suc | `branch` | string | Source branch name \(refs/heads/ prefix stripped\) | | `commitSha` | string | Source commit SHA | | `triggeredBy` | string | Display name of the person who triggered the build | -| `triggeredByEmail` | string | Email/unique name of the person who triggered the build | +| `triggeredByEmail` | string | Email/unique name of the person who triggered the build, or null if not set | | `startTime` | string | Build start time \(ISO 8601\) | | `finishTime` | string | Build finish time \(ISO 8601\) | | `buildUrl` | string | API URL for the build resource | @@ -72,12 +72,12 @@ Trigger workflow when a work item is created in Azure DevOps | `workItemType` | string | Work item type for Basic process \(e.g. Issue, Task, Epic\) | | `title` | string | Work item title | | `state` | string | Work item state for Basic process \(e.g. To Do, Doing, Done\) | -| `createdBy` | string | Display name of the creator | -| `assignedTo` | string | Assignee display name, or empty string if unassigned | +| `createdBy` | string | Display name of the creator, or null if not set | +| `assignedTo` | string | Assignee display name, or null if unassigned | | `priority` | number | Priority \(1–4\), or 0 if not set | | `areaPath` | string | Area path | | `iterationPath` | string | Iteration path | -| `description` | string | Work item description \(HTML\), or empty string if not set | +| `description` | string | Work item description \(HTML\), or null if not set | | `projectName` | string | Azure DevOps project name | | `workItemUrl` | string | API URL for the work item resource | diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index 2394d741345..ff677b09153 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -57,7 +57,6 @@ import { EvernoteIcon, ExaAIIcon, ExtendIcon, - EyeIcon, FathomIcon, FindymailIcon, FirecrawlIcon, @@ -405,7 +404,6 @@ export const blockTypeToIconMap: Record = { upstash: UpstashIcon, vercel: VercelIcon, video_generator_v3: VideoIcon, - vision_v2: EyeIcon, wealthbox: WealthboxIcon, webflow: WebflowIcon, whatsapp: WhatsAppIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index fdf1e75c533..bb13c23c653 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -1938,7 +1938,7 @@ }, { "name": "Get Work Items Batch", - "description": "Fetch full details for multiple work items by ID from Azure DevOps in a single call. Pass comma-separated IDs (e.g. " + "description": "Fetch full details for multiple work items by ID from Azure DevOps. Pass comma-separated IDs (e.g. " }, { "name": "Create Work Item", @@ -4156,10 +4156,6 @@ "name": "Fetch", "description": "Fetch and parse a file from a URL with optional custom headers." }, - { - "name": "Get", - "description": "Get a workspace file object from a selected file or canonical workspace file ID." - }, { "name": "Write", "description": "Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., " @@ -4169,7 +4165,7 @@ "description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file." } ], - "operationCount": 5, + "operationCount": 4, "triggers": [], "triggerCount": 0, "authType": "none", @@ -14253,24 +14249,6 @@ "integrationTypes": ["ai", "design"], "tags": ["video-generation", "llm"] }, - { - "type": "vision_v2", - "slug": "vision", - "name": "Vision", - "description": "Analyze images with vision models", - "longDescription": "Integrate Vision into the workflow. Can analyze images with vision models.", - "bgColor": "#4D5FFF", - "iconName": "EyeIcon", - "docsUrl": "https://docs.sim.ai/tools/vision", - "operations": [], - "operationCount": 0, - "triggers": [], - "triggerCount": 0, - "authType": "api-key", - "category": "tools", - "integrationTypes": ["ai", "documents"], - "tags": ["llm", "document-processing", "ocr"] - }, { "type": "wealthbox", "slug": "wealthbox", diff --git a/apps/sim/app/api/files/parse/route.test.ts b/apps/sim/app/api/files/parse/route.test.ts index eb421bf450e..b2ab510c9f8 100644 --- a/apps/sim/app/api/files/parse/route.test.ts +++ b/apps/sim/app/api/files/parse/route.test.ts @@ -31,8 +31,6 @@ const { mockFsWriteFile, mockJoin, actualPath, - mockFileExistsInWorkspace, - mockListWorkspaceFiles, mockUploadWorkspaceFile, } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -62,9 +60,19 @@ const { return actualPath.join(...args) }), actualPath, - mockFileExistsInWorkspace: vi.fn().mockResolvedValue(false), - mockListWorkspaceFiles: vi.fn().mockResolvedValue([]), - mockUploadWorkspaceFile: vi.fn().mockResolvedValue({}), + mockUploadWorkspaceFile: vi + .fn() + .mockImplementation( + async (workspaceId: string, _userId: string, _buffer: Buffer, fileName: string) => ({ + id: 'wf_test', + name: fileName, + size: 0, + type: 'application/octet-stream', + url: `/api/files/serve/${workspaceId}/${fileName}`, + key: `${workspaceId}/${fileName}`, + context: 'workspace', + }) + ), } }) @@ -110,9 +118,7 @@ vi.mock('@/lib/uploads/contexts/execution', () => ({ uploadExecutionFile: vi.fn(), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - fileExistsInWorkspace: mockFileExistsInWorkspace, - listWorkspaceFiles: mockListWorkspaceFiles, +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ uploadWorkspaceFile: mockUploadWorkspaceFile, })) @@ -190,9 +196,7 @@ describe('File Parse API Route', () => { mockFsStat.mockResolvedValue({ isFile: () => true, size: 17 }) mockFsReadFile.mockResolvedValue(Buffer.from('test file content')) mockIsSupportedFileType.mockReturnValue(true) - mockFileExistsInWorkspace.mockResolvedValue(false) - mockListWorkspaceFiles.mockResolvedValue([]) - mockUploadWorkspaceFile.mockResolvedValue({}) + mockUploadWorkspaceFile.mockClear() mockParseFile.mockResolvedValue({ content: 'parsed content', metadata: { pageCount: 1 }, @@ -384,34 +388,32 @@ describe('File Parse API Route', () => { ) }) - it('should preserve the full download cap when an external URL reuses a workspace file', async () => { + it('should never dedup external URL fetches by path filename — two URLs sharing image.png both download', async () => { inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10', }) - inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( - new Response('file content', { - status: 200, - headers: { 'content-type': 'text/plain' }, - }) - ) - mockFileExistsInWorkspace.mockResolvedValueOnce(false).mockResolvedValueOnce(true) - mockListWorkspaceFiles.mockResolvedValueOnce([ - { name: 'file2.txt', key: 'workspace-file2.txt' }, - ]) - - mockParseBuffer - .mockResolvedValueOnce({ - content: 'a'.repeat(4 * 1024 * 1024), - metadata: { pageCount: 1 }, - }) - .mockResolvedValueOnce({ - content: 'second file', - metadata: { pageCount: 1 }, - }) + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + new Response('first image bytes', { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + ) + .mockResolvedValueOnce( + new Response('second image bytes — different content', { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + ) + mockIsSupportedFileType.mockReturnValue(false) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') const req = createMockRequest('POST', { - filePath: ['https://example.com/file1.txt', 'https://example.com/file2.txt'], + filePath: [ + 'https://files.slack.com/files-pri/T07-FAAA/download/image.png', + 'https://files.slack.com/files-pri/T07-FBBB/download/image.png', + ], workspaceId: 'workspace-id', }) @@ -420,9 +422,21 @@ describe('File Parse API Route', () => { expect(response.status).toBe(200) expect(data.results).toHaveLength(2) - expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith( - expect.objectContaining({ key: 'workspace-file2.txt', maxBytes: 100 * 1024 * 1024 }) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 1, + 'https://files.slack.com/files-pri/T07-FAAA/download/image.png', + '203.0.113.10', + expect.any(Object) + ) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenNthCalledWith( + 2, + 'https://files.slack.com/files-pri/T07-FBBB/download/image.png', + '203.0.113.10', + expect.any(Object) ) + expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(2) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() }) it('should stop multi-file parsing once the combined parsed output is too large', async () => { diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index d015bc1cf66..ea4f493dd80 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -10,21 +10,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { fileParseContract } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' -import { - assertKnownSizeWithinLimit, - DEFAULT_MAX_ERROR_BODY_BYTES, - isPayloadSizeLimitError, - readResponseTextWithLimit, - readResponseToBufferWithLimit, -} from '@/lib/core/utils/stream-limits' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { + ExternalUrlValidationError, + fetchExternalUrlToWorkspace, +} from '@/lib/uploads/contexts/workspace' import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { @@ -36,7 +30,6 @@ import { inferContextFromKey, isInternalFileUrl, } from '@/lib/uploads/utils/file-utils' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import '@/lib/uploads/core/setup.server' @@ -453,9 +446,16 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string } /** - * Handle external URL - * If workspaceId is provided, checks if file already exists and saves to workspace if not - * If executionContext is provided, also stores the file in execution storage and returns UserFile + * Handle external URL. + * + * Always fetches the URL fresh — there is no filename-based dedup. Distinct URLs + * commonly share a path tail (e.g. every Slack clipboard paste is `image.png`), + * so keying a cache by filename returns stale bytes. `fetchExternalUrlToWorkspace` + * delegates to `uploadWorkspaceFile`, which suffix-disambiguates collisions on save. + * + * Workspace save is skipped when the URL already points at our execution-files + * bucket (re-uploading our own bytes is wasteful and would generate `image (1).png` + * style aliases for files we already own). */ async function handleExternalUrl( url: string, @@ -470,23 +470,6 @@ async function handleExternalUrl( ): Promise { try { logger.info('Fetching external URL:', url) - logger.info('WorkspaceId for URL save:', workspaceId) - - const urlValidation = await validateUrlWithDNS(url, 'fileUrl') - if (!urlValidation.isValid) { - logger.warn(`Blocked external URL request: ${urlValidation.error}`) - return { - success: false, - error: urlValidation.error || 'Invalid external URL', - filePath: url, - } - } - - const urlPath = new URL(url).pathname - const filename = urlPath.split('/').pop() || 'download' - const extension = path.extname(filename).toLowerCase().substring(1) - - logger.info(`Extracted filename: ${filename}, workspaceId: ${workspaceId}`) const { S3_EXECUTION_FILES_CONFIG, @@ -511,104 +494,27 @@ async function handleExternalUrl( isExecutionFile = false } - // Only apply workspace deduplication if: - // 1. WorkspaceId is provided - // 2. URL is NOT from execution files bucket/container - const shouldCheckWorkspace = workspaceId && !isExecutionFile - - if (shouldCheckWorkspace) { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - logger.warn('User does not have workspace access for file parse', { - userId, - workspaceId, - filename, - }) - return { - success: false, - error: 'File not found', - filePath: url, - } - } - - const { fileExistsInWorkspace, listWorkspaceFiles } = await import( - '@/lib/uploads/contexts/workspace' - ) - const exists = await fileExistsInWorkspace(workspaceId, filename) - - if (exists) { - logger.info(`File ${filename} already exists in workspace, using existing file`) - const workspaceFiles = await listWorkspaceFiles(workspaceId) - const existingFile = workspaceFiles.find((f) => f.name === filename) - - if (existingFile) { - const storageFilePath = `/api/files/serve/${existingFile.key}` - return handleCloudFile( - storageFilePath, - fileType, - 'workspace', - userId, - executionContext, - maxDownloadBytes, - maxParsedOutputBytes - ) - } - } - } - - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { - timeout: DOWNLOAD_TIMEOUT_MS, - maxResponseBytes: maxDownloadBytes, - signal, - ...(headers && Object.keys(headers).length > 0 && { headers }), - }) - if (!response.ok) { - await readResponseTextWithLimit(response, { - maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, - label: 'file download error response', - signal, - }).catch(() => '') - throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`) - } - - const buffer = await readResponseToBufferWithLimit(response, { - maxBytes: maxDownloadBytes, - label: 'file download', + const { filename, buffer, mimeType } = await fetchExternalUrlToWorkspace({ + url, + userId, + workspaceId: workspaceId || undefined, + saveToWorkspace: Boolean(workspaceId) && !isExecutionFile, + headers, signal, + maxDownloadBytes, + timeoutMs: DOWNLOAD_TIMEOUT_MS, }) + const extension = path.extname(filename).toLowerCase().substring(1) logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`) let userFile: UserFile | undefined - const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension) - if (executionContext) { try { userFile = await uploadExecutionFile(executionContext, buffer, filename, mimeType, userId) logger.info(`Stored file in execution storage: ${filename}`, { key: userFile.key }) } catch (uploadError) { - logger.warn(`Failed to store file in execution storage:`, uploadError) - // Continue without userFile - parsing can still work - } - } - - if (shouldCheckWorkspace) { - try { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn('User does not have write permission for workspace file save', { - userId, - workspaceId, - filename, - permission, - }) - } else { - const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') - await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType) - logger.info(`Saved URL file to workspace storage: ${filename}`) - } - } catch (saveError) { - logger.warn(`Failed to save URL file to workspace:`, saveError) + logger.warn('Failed to store file in execution storage:', uploadError) } } @@ -657,6 +563,15 @@ async function handleExternalUrl( } } + if (error instanceof ExternalUrlValidationError) { + logger.warn(`Blocked external URL request: ${error.message}`) + return { + success: false, + error: error.message, + filePath: url, + } + } + return { success: false, error: `Error fetching URL: ${(error as Error).message}`, diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index b1643542402..6476b53f5c9 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -32,6 +32,7 @@ import { } from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' const logger = createLogger('ImageProxyAPI') const MAX_IMAGE_BYTES = 25 * 1024 * 1024 @@ -53,6 +54,7 @@ interface GeneratedImageResult { revisedPrompt?: string seed?: number jobId?: string + falaiCost?: FalAICostMetadata } interface StoredImageResponse { @@ -72,6 +74,8 @@ interface StoredImageResponse { jobId?: string contentType: string } + __falaiCostDollars?: number + __falaiBilling?: FalAICostMetadata } export const POST = withRouteHandler(async (request: NextRequest) => { @@ -935,6 +939,13 @@ async function generateWithFalAI( revisedPrompt: getStringProperty(resultData, 'revised_prompt'), seed: getNumberProperty(resultData, 'seed'), jobId: falRequestId, + falaiCost: body.useHostedCostTracking + ? await getFalAICostMetadata({ + apiKey, + endpointId: modelConfig.endpoint, + requestId: falRequestId, + }) + : undefined, } } @@ -992,6 +1003,8 @@ async function storeGeneratedImage( jobId: imageResult.jobId, contentType: imageResult.contentType, }, + __falaiCostDollars: imageResult.falaiCost?.costDollars, + __falaiBilling: imageResult.falaiCost, } } @@ -1024,5 +1037,7 @@ async function storeGeneratedImage( jobId: imageResult.jobId, contentType: imageResult.contentType, }, + __falaiCostDollars: imageResult.falaiCost?.costDollars, + __falaiBilling: imageResult.falaiCost, } } diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 0a6f3ddead4..1110432a473 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -17,6 +17,7 @@ import { readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' @@ -138,6 +139,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let height: number | undefined let jobId: string | undefined let actualDuration: number | undefined + let falaiCost: FalAICostMetadata | undefined if (body.visualReference) { const denied = await assertToolFileAccess( @@ -236,6 +238,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { resolution, body.promptOptimizer, body.generateAudio, + body.useHostedCostTracking === true, requestId, logger ) @@ -244,6 +247,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { height = result.height jobId = result.jobId actualDuration = result.duration + falaiCost = result.falaiCost } else { return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 }) } @@ -301,6 +305,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { provider, model: model || 'default', jobId, + __falaiCostDollars: falaiCost?.costDollars, + __falaiBilling: falaiCost, }) } @@ -333,6 +339,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { provider, model: model || 'default', jobId, + __falaiCostDollars: falaiCost?.costDollars, + __falaiBilling: falaiCost, }) } catch (error) { logger.error(`[${requestId}] Video proxy error:`, error) @@ -1167,9 +1175,17 @@ async function generateWithFalAI( resolution: string | undefined, promptOptimizer: boolean | undefined, generateAudio: boolean | undefined, + useHostedCostTracking: boolean, requestId: string, logger: ReturnType -): Promise<{ buffer: Buffer; width: number; height: number; jobId: string; duration: number }> { +): Promise<{ + buffer: Buffer + width: number + height: number + jobId: string + duration: number + falaiCost?: FalAICostMetadata +}> { logger.info(`[${requestId}] Starting Fal.ai generation with model: ${model}`) const modelConfig = FALAI_MODEL_CONFIGS[model] @@ -1312,6 +1328,13 @@ async function generateWithFalAI( height, jobId: requestIdFal, duration: getNumberProperty(videoOutput, 'duration') || duration || 5, + falaiCost: useHostedCostTracking + ? await getFalAICostMetadata({ + apiKey, + endpointId: modelConfig.endpoint, + requestId: requestIdFal, + }) + : undefined, } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index d2490ec46f6..93512d9467f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -219,6 +219,36 @@ export const ResourceTable = memo(function ResourceTable({ direction: 'desc', }) + const [contextMenuRowId, setContextMenuRowId] = useState(null) + + const wrappedOnRowContextMenu = useCallback( + (e: React.MouseEvent, rowId: string) => { + setContextMenuRowId(rowId) + onRowContextMenu?.(e, rowId) + }, + [onRowContextMenu] + ) + + useEffect(() => { + if (!contextMenuRowId) return + const clear = () => setContextMenuRowId(null) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', handleKeyDown) + clear() + } + } + const timeoutId = setTimeout(() => { + document.addEventListener('pointerdown', clear, { once: true }) + document.addEventListener('keydown', handleKeyDown) + }, 0) + return () => { + clearTimeout(timeoutId) + document.removeEventListener('pointerdown', clear) + document.removeEventListener('keydown', handleKeyDown) + } + }, [contextMenuRowId]) + const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => { setInternalSort({ column, direction }) }, []) @@ -343,7 +373,8 @@ export const ResourceTable = memo(function ResourceTable({ rowDragDrop={rowDragDrop} onRowClick={onRowClick} onRowHover={onRowHover} - onRowContextMenu={onRowContextMenu} + onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined} + isContextMenuTarget={contextMenuRowId === row.id} hasCheckbox={hasCheckbox} /> ))} @@ -403,17 +434,18 @@ const Pagination = memo(function Pagination({ } if (page < 1 || page > totalPages) return null return ( - + ) })} @@ -460,6 +492,7 @@ interface DataRowProps { onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void + isContextMenuTarget?: boolean hasCheckbox: boolean } @@ -472,6 +505,7 @@ const DataRow = memo(function DataRow({ onRowClick, onRowHover, onRowContextMenu, + isContextMenuTarget, hasCheckbox, }: DataRowProps) { const isSelected = selectable?.selectedIds.has(row.id) ?? false @@ -554,7 +588,7 @@ const DataRow = memo(function DataRow({ onRowClick && 'cursor-pointer', isDraggable && 'cursor-grab active:cursor-grabbing', isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]', - (selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]', + (selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]', isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]', (isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50' )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 1e715a8a373..6aa7d5a40b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1585,10 +1585,11 @@ export function Files() { } : undefined, dropdownItems: - isCurrentFolder && canEdit + isCurrentFolder && (canEdit || userPermissions.isLoading) ? [ { label: 'Rename', + disabled: !canEdit, onClick: () => breadcrumbRenameRef.current.startRename(folder.id, folder.name), }, ] @@ -1605,6 +1606,7 @@ export function Files() { router, workspaceId, canEdit, + userPermissions.isLoading, breadcrumbRename.editingId, breadcrumbRename.editValue, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 301cdd26be0..43071af70a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -814,15 +814,26 @@ export function KnowledgeBase({ } : undefined, dropdownItems: [ - ...(userPermissions.canEdit + ...(userPermissions.canEdit || userPermissions.isLoading ? [ { label: 'Rename', icon: Pencil, + disabled: !userPermissions.canEdit, onClick: () => kbRename.startRename(id, knowledgeBaseName), }, - { label: 'Tags', icon: Tag, onClick: () => setShowTagsModal(true) }, - { label: 'Delete', icon: Trash, onClick: () => setShowDeleteDialog(true) }, + { + label: 'Tags', + icon: Tag, + disabled: !userPermissions.canEdit, + onClick: () => setShowTagsModal(true), + }, + { + label: 'Delete', + icon: Trash, + disabled: !userPermissions.canEdit, + onClick: () => setShowDeleteDialog(true), + }, ] : []), ], @@ -830,8 +841,15 @@ export function KnowledgeBase({ ] const headerActions: HeaderAction[] = [ - ...(userPermissions.canEdit - ? [{ label: 'New connector', icon: Plus, onClick: () => setShowAddConnectorModal(true) }] + ...(userPermissions.canEdit || userPermissions.isLoading + ? [ + { + label: 'New connector', + icon: Plus, + disabled: !userPermissions.canEdit, + onClick: () => setShowAddConnectorModal(true), + }, + ] : []), ] @@ -886,18 +904,18 @@ export function KnowledgeBase({ /> {enabledFilter.length > 0 && ( - + )} - + Connected Sources Manage connected data sources for this knowledge base @@ -1535,13 +1553,13 @@ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectio >
- +
{step === 'select-type' ? (
-
+
- +
{config.name} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index ea39d97cfac..4106c94d286 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -72,7 +72,7 @@ export function ConnectorSelectorField({ if (isLoading && isEnabled) { return ( -
+
Loading…
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index b388ff2b246..a70738c7cd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -372,7 +372,7 @@ function ConnectorCard({
- {Icon && } + {Icon && } {connector.status === 'disabled' && ( )} @@ -532,7 +532,7 @@ function ConnectorCard({

{canEdit && serviceId && providerId && (
{canEdit && (