diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index e527643bb4..eae6ab94ac 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -1,5 +1,6 @@ import type { RequestParams, RequestResponse } from '@/tools/http/types' -import { getDefaultHeaders, processUrl, transformTable } from '@/tools/http/utils' +import { getDefaultHeaders, processUrl } from '@/tools/http/utils' +import { transformTable } from '@/tools/shared/table' import type { ToolConfig } from '@/tools/types' export const requestTool: ToolConfig = { diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index eb108fef36..876cc91da5 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { isTest } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' +import { transformTable } from '@/tools/shared/table' import type { TableRow } from '@/tools/types' const logger = createLogger('HTTPRequestUtils') @@ -119,28 +120,3 @@ export const shouldUseProxy = (url: string): boolean => { return false } } - -/** - * Transforms a table from the store format to a key-value object - * Local copy of the function to break circular dependencies - * @param table Array of table rows from the store - * @returns Record of key-value pairs - */ -export const transformTable = (table: TableRow[] | null): Record => { - if (!table) return {} - - return table.reduce( - (acc, row) => { - if (row.cells?.Key && row.cells?.Value !== undefined) { - // Extract the Value cell as is - it should already be properly resolved - // by the InputResolver based on variable type (number, string, boolean etc.) - const value = row.cells.Value - - // Store the correctly typed value in the result object - acc[row.cells.Key] = value - } - return acc - }, - {} as Record - ) -} diff --git a/apps/sim/tools/knowledge/create_document.ts b/apps/sim/tools/knowledge/create_document.ts index 10581852d1..5b82d504a7 100644 --- a/apps/sim/tools/knowledge/create_document.ts +++ b/apps/sim/tools/knowledge/create_document.ts @@ -1,6 +1,6 @@ import type { KnowledgeCreateDocumentResponse } from '@/tools/knowledge/types' -import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/params' import { enrichKBTagsSchema } from '@/tools/schema-enrichers' +import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/shared/tags' import type { ToolConfig } from '@/tools/types' export const knowledgeCreateDocumentTool: ToolConfig = { diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index d611125fd8..574017d083 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -1,6 +1,6 @@ import type { KnowledgeSearchResponse } from '@/tools/knowledge/types' -import { parseTagFilters } from '@/tools/params' import { enrichKBTagFiltersSchema } from '@/tools/schema-enrichers' +import { parseTagFilters } from '@/tools/shared/tags' import type { ToolConfig } from '@/tools/types' export const knowledgeSearchTool: ToolConfig = { diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index aa39ecfd89..ee6f3076ad 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' -import type { StructuredFilter } from '@/lib/knowledge/types' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { evaluateSubBlockCondition, type SubBlockCondition, } from '@/lib/workflows/subblocks/visibility' import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' +import { isEmptyTagValue } from '@/tools/shared/tags' import type { ParameterVisibility, ToolConfig } from '@/tools/types' import { getTool } from '@/tools/utils' @@ -23,194 +23,6 @@ export function isNonEmpty(value: unknown): boolean { // Tag/Value Parsing Utilities // ============================================================================ -/** - * Document tag entry format used in create_document tool - */ -export interface DocumentTagEntry { - tagName: string - value: string -} - -/** - * Tag filter entry format used in search tool - */ -export interface TagFilterEntry { - tagName: string - tagSlot?: string - tagValue: string | number | boolean - fieldType?: string - operator?: string - valueTo?: string | number -} - -/** - * Checks if a tag value is effectively empty (unfilled/default entry) - */ -function isEmptyTagEntry(entry: Record): boolean { - if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) { - return true - } - return false -} - -/** - * Checks if a tag-based value is effectively empty (only contains default/unfilled entries). - * Works for both documentTags and tagFilters parameters in various formats. - * - * @param value - The tag value to check (can be JSON string, array, or object) - * @returns true if the value is empty or only contains unfilled entries - */ -export function isEmptyTagValue(value: unknown): boolean { - if (!value) return true - - // Handle JSON string format - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) - if (!Array.isArray(parsed)) return false - if (parsed.length === 0) return true - return parsed.every((entry: Record) => isEmptyTagEntry(entry)) - } catch { - return false - } - } - - // Handle array format directly - if (Array.isArray(value)) { - if (value.length === 0) return true - return value.every((entry: Record) => isEmptyTagEntry(entry)) - } - - // Handle object format (LLM format: { "Category": "foo", "Priority": 5 }) - if (typeof value === 'object' && value !== null) { - const entries = Object.entries(value) - if (entries.length === 0) return true - return entries.every(([, val]) => val === undefined || val === null || val === '') - } - - return false -} - -/** - * Filters valid document tags from an array, removing empty entries - */ -function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] { - return tags - .filter((entry): entry is Record => { - if (typeof entry !== 'object' || entry === null) return false - const e = entry as Record - if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false - if (e.value === undefined || e.value === null || e.value === '') return false - return true - }) - .map((entry) => ({ - tagName: String(entry.tagName), - value: String(entry.value), - })) -} - -/** - * Parses document tags from various formats into a normalized array format. - * Used by create_document tool to handle tags from both UI and LLM sources. - * - * @param value - Document tags in object, array, or JSON string format - * @returns Normalized array of document tag entries, or empty array if invalid - */ -export function parseDocumentTags(value: unknown): DocumentTagEntry[] { - if (!value) return [] - - // Handle object format from LLM: { "Category": "foo", "Priority": 5 } - if (typeof value === 'object' && !Array.isArray(value) && value !== null) { - return Object.entries(value) - .filter(([tagName, tagValue]) => { - if (!tagName || tagName.trim() === '') return false - if (tagValue === undefined || tagValue === null || tagValue === '') return false - return true - }) - .map(([tagName, tagValue]) => ({ - tagName, - value: String(tagValue), - })) - } - - // Handle JSON string format from UI - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) - if (Array.isArray(parsed)) { - return filterValidDocumentTags(parsed) - } - } catch { - // Invalid JSON, return empty - } - return [] - } - - // Handle array format directly - if (Array.isArray(value)) { - return filterValidDocumentTags(value) - } - - return [] -} - -/** - * Parses tag filters from various formats into a normalized StructuredFilter array. - * Used by search tool to handle tag filters from both UI and LLM sources. - * - * @param value - Tag filters in array or JSON string format - * @returns Normalized array of structured filters, or empty array if invalid - */ -export function parseTagFilters(value: unknown): StructuredFilter[] { - if (!value) return [] - - let tagFilters = value - - // Handle JSON string format - if (typeof tagFilters === 'string') { - try { - tagFilters = JSON.parse(tagFilters) - } catch { - return [] - } - } - - // Must be an array at this point - if (!Array.isArray(tagFilters)) return [] - - return tagFilters - .filter((filter): filter is Record => { - if (typeof filter !== 'object' || filter === null) return false - const f = filter as Record - if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false - if (f.fieldType === 'boolean') { - return f.tagValue !== undefined - } - if (f.tagValue === undefined || f.tagValue === null) return false - if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false - return true - }) - .map((filter) => ({ - tagName: filter.tagName as string, - tagSlot: (filter.tagSlot as string) || '', - fieldType: (filter.fieldType as string) || 'text', - operator: (filter.operator as string) || 'eq', - value: filter.tagValue as string | number | boolean, - valueTo: filter.valueTo as string | number | undefined, - })) -} - -/** - * Converts parsed document tags to the format expected by the create document API. - * Returns the documentTagsData JSON string if there are valid tags. - */ -export function formatDocumentTagsForAPI(tags: DocumentTagEntry[]): { documentTagsData?: string } { - if (tags.length === 0) return {} - return { - documentTagsData: JSON.stringify(tags), - } -} - export interface Option { label: string value: string diff --git a/apps/sim/tools/shared/table.ts b/apps/sim/tools/shared/table.ts new file mode 100644 index 0000000000..c859c6fbd5 --- /dev/null +++ b/apps/sim/tools/shared/table.ts @@ -0,0 +1,38 @@ +import type { TableRow } from '@/tools/types' + +/** + * Transforms a table from the store format to a key-value object. + */ +export const transformTable = ( + table: TableRow[] | Record | string | null +): Record => { + if (!table) return {} + + if (typeof table === 'string') { + try { + const parsed = JSON.parse(table) as TableRow[] | Record + return transformTable(parsed) + } catch { + return {} + } + } + + if (Array.isArray(table)) { + return table.reduce( + (acc, row) => { + if (row.cells?.Key && row.cells?.Value !== undefined) { + const value = row.cells.Value + acc[row.cells.Key] = value + } + return acc + }, + {} as Record + ) + } + + if (typeof table === 'object') { + return table + } + + return {} +} diff --git a/apps/sim/tools/shared/tags.ts b/apps/sim/tools/shared/tags.ts new file mode 100644 index 0000000000..60088dba3e --- /dev/null +++ b/apps/sim/tools/shared/tags.ts @@ -0,0 +1,168 @@ +import type { StructuredFilter } from '@/lib/knowledge/types' + +/** + * Document tag entry format used in create_document tool. + */ +export interface DocumentTagEntry { + tagName: string + value: string +} + +/** + * Tag filter entry format used in search tool. + */ +export interface TagFilterEntry { + tagName: string + tagSlot?: string + tagValue: string | number | boolean + fieldType?: string + operator?: string + valueTo?: string | number +} + +/** + * Checks if a tag value is effectively empty (unfilled/default entry). + */ +function isEmptyTagEntry(entry: Record): boolean { + if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) { + return true + } + return false +} + +/** + * Checks if a tag-based value is effectively empty (only contains default/unfilled entries). + */ +export function isEmptyTagValue(value: unknown): boolean { + if (!value) return true + + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + if (!Array.isArray(parsed)) return false + if (parsed.length === 0) return true + return parsed.every((entry: Record) => isEmptyTagEntry(entry)) + } catch { + return false + } + } + + if (Array.isArray(value)) { + if (value.length === 0) return true + return value.every((entry: Record) => isEmptyTagEntry(entry)) + } + + if (typeof value === 'object' && value !== null) { + const entries = Object.entries(value) + if (entries.length === 0) return true + return entries.every(([, val]) => val === undefined || val === null || val === '') + } + + return false +} + +/** + * Filters valid document tags from an array, removing empty entries. + */ +function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] { + return tags + .filter((entry): entry is Record => { + if (typeof entry !== 'object' || entry === null) return false + const e = entry as Record + if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false + if (e.value === undefined || e.value === null || e.value === '') return false + return true + }) + .map((entry) => ({ + tagName: String(entry.tagName), + value: String(entry.value), + })) +} + +/** + * Parses document tags from various formats into a normalized array format. + */ +export function parseDocumentTags(value: unknown): DocumentTagEntry[] { + if (!value) return [] + + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + return Object.entries(value) + .filter(([tagName, tagValue]) => { + if (!tagName || tagName.trim() === '') return false + if (tagValue === undefined || tagValue === null || tagValue === '') return false + return true + }) + .map(([tagName, tagValue]) => ({ + tagName, + value: String(tagValue), + })) + } + + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + return filterValidDocumentTags(parsed) + } + } catch { + return [] + } + return [] + } + + if (Array.isArray(value)) { + return filterValidDocumentTags(value) + } + + return [] +} + +/** + * Parses tag filters from various formats into a normalized StructuredFilter array. + */ +export function parseTagFilters(value: unknown): StructuredFilter[] { + if (!value) return [] + + let tagFilters = value + + if (typeof tagFilters === 'string') { + try { + tagFilters = JSON.parse(tagFilters) + } catch { + return [] + } + } + + if (!Array.isArray(tagFilters)) return [] + + return tagFilters + .filter((filter): filter is Record => { + if (typeof filter !== 'object' || filter === null) return false + const f = filter as Record + if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false + if (f.fieldType === 'boolean') { + return f.tagValue !== undefined + } + if (f.tagValue === undefined || f.tagValue === null) return false + if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false + return true + }) + .map((filter) => ({ + tagName: filter.tagName as string, + tagSlot: (filter.tagSlot as string) || '', + fieldType: (filter.fieldType as string) || 'text', + operator: (filter.operator as string) || 'eq', + value: filter.tagValue as string | number | boolean, + valueTo: filter.valueTo as string | number | undefined, + })) +} + +/** + * Converts parsed document tags to the format expected by the create document API. + */ +export function formatDocumentTagsForAPI(tags: DocumentTagEntry[]): { documentTagsData?: string } { + if (tags.length === 0) return {} + return { + documentTagsData: JSON.stringify(tags), + } +} diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index 5f776ec55b..5eae3eb767 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -1,5 +1,6 @@ import { createMockFetch, loggerMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { transformTable } from '@/tools/shared/table' import type { ToolConfig } from '@/tools/types' import { createCustomToolRequestBody, @@ -7,7 +8,6 @@ import { executeRequest, formatRequestParams, getClientEnvVars, - transformTable, validateRequiredParametersAfterMerge, } from '@/tools/utils' @@ -91,6 +91,25 @@ describe('transformTable', () => { enabled: false, }) }) + + it.concurrent('should parse JSON string inputs and transform rows', () => { + const table = [ + { id: '1', cells: { Key: 'city', Value: 'SF' } }, + { id: '2', cells: { Key: 'temp', Value: 64 } }, + ] + const result = transformTable(JSON.stringify(table)) + + expect(result).toEqual({ + city: 'SF', + temp: 64, + }) + }) + + it.concurrent('should parse JSON string object inputs', () => { + const result = transformTable(JSON.stringify({ a: 1, b: 'two' })) + + expect(result).toEqual({ a: 1, b: 'two' }) + }) }) describe('formatRequestParams', () => { diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index fe067e2198..9e09d90c5a 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -5,7 +5,7 @@ import { useCustomToolsStore } from '@/stores/custom-tools' import { useEnvironmentStore } from '@/stores/settings/environment' import { extractErrorMessage } from '@/tools/error-extractors' import { tools } from '@/tools/registry' -import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' const logger = createLogger('ToolsUtils') @@ -70,30 +70,6 @@ export function resolveToolId(toolName: string): string { return toolName } -/** - * Transforms a table from the store format to a key-value object - * @param table Array of table rows from the store - * @returns Record of key-value pairs - */ -export const transformTable = (table: TableRow[] | null): Record => { - if (!table) return {} - - return table.reduce( - (acc, row) => { - if (row.cells?.Key && row.cells?.Value !== undefined) { - // Extract the Value cell as is - it should already be properly resolved - // by the InputResolver based on variable type (number, string, boolean etc.) - const value = row.cells.Value - - // Store the correctly typed value in the result object - acc[row.cells.Key] = value - } - return acc - }, - {} as Record - ) -} - interface RequestParams { url: string method: string