Files
simstudioai--sim/apps/sim/hooks/queries/custom-tools.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

363 lines
10 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
deleteCustomToolContract,
listCustomToolsContract,
upsertCustomToolsContract,
} from '@/lib/api/contracts/tools/custom'
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
const logger = createLogger('CustomToolsQueries')
export const CUSTOM_TOOL_LIST_STALE_TIME = 60 * 1000
interface CustomToolSchema {
[key: string]: unknown
type: 'function'
function: {
[key: string]: unknown
name: string
description?: string
parameters: {
[key: string]: unknown
type: string
properties: Record<string, unknown>
required?: string[]
}
}
}
export interface CustomToolDefinition {
id: string
workspaceId: string | null
userId: string | null
title: string
schema: CustomToolSchema
code: string
createdAt: string
updatedAt?: string
}
export type CustomTool = CustomToolDefinition
type ApiCustomTool = Partial<CustomToolDefinition> & {
id: string
title: string
schema: Partial<CustomToolSchema> & {
function?: Partial<CustomToolSchema['function']> & {
parameters?: Partial<CustomToolSchema['function']['parameters']>
}
}
code?: string
}
function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomToolDefinition {
const fallbackName = tool.schema.function?.name || tool.id
const parameters = tool.schema.function?.parameters ?? {
type: 'object',
properties: {},
}
return {
id: tool.id,
title: tool.title,
code: typeof tool.code === 'string' ? tool.code : '',
workspaceId: tool.workspaceId ?? workspaceId ?? null,
userId: tool.userId ?? null,
createdAt:
typeof tool.createdAt === 'string'
? tool.createdAt
: tool.updatedAt && typeof tool.updatedAt === 'string'
? tool.updatedAt
: new Date().toISOString(),
updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined,
schema: {
type: tool.schema.type ?? 'function',
function: {
name: fallbackName,
description: tool.schema.function?.description,
parameters: {
type: parameters.type ?? 'object',
properties: parameters.properties ?? {},
required: parameters.required,
},
},
},
}
}
/**
* Fetch custom tools for a workspace
*/
async function fetchCustomTools(
workspaceId: string,
signal?: AbortSignal
): Promise<CustomToolDefinition[]> {
const { data } = await requestJson(listCustomToolsContract, {
query: { workspaceId },
signal,
})
const normalizedTools: CustomToolDefinition[] = []
data.forEach((tool, index) => {
if (!isRecordLike(tool)) {
logger.warn(`Skipping invalid tool at index ${index}: not an object`)
return
}
if (!tool.id || typeof tool.id !== 'string') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid id`)
return
}
if (!tool.title || typeof tool.title !== 'string') {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`)
return
}
if (!isRecordLike(tool.schema)) {
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`)
return
}
if (!isRecordLike(tool.schema.function)) {
logger.warn(`Skipping invalid tool at index ${index}: missing function schema`)
return
}
const functionSchema = tool.schema.function
const parameters = isRecordLike(functionSchema.parameters) ? functionSchema.parameters : {}
const properties = isRecordLike(parameters.properties) ? parameters.properties : {}
const required = Array.isArray(parameters.required)
? parameters.required.filter((value): value is string => typeof value === 'string')
: undefined
const apiTool: ApiCustomTool = {
id: tool.id,
title: tool.title,
schema: {
type: 'function',
function: {
name: typeof functionSchema.name === 'string' ? functionSchema.name : tool.id,
description:
typeof functionSchema.description === 'string' ? functionSchema.description : undefined,
parameters: {
type: typeof parameters.type === 'string' ? parameters.type : 'object',
properties,
required,
},
},
},
code: typeof tool.code === 'string' ? tool.code : '',
workspaceId: typeof tool.workspaceId === 'string' ? tool.workspaceId : null,
userId: typeof tool.userId === 'string' ? tool.userId : null,
createdAt: typeof tool.createdAt === 'string' ? tool.createdAt : undefined,
updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined,
}
try {
normalizedTools.push(normalizeCustomTool(apiTool, workspaceId))
} catch (error) {
logger.warn(`Failed to normalize custom tool at index ${index}`, { error })
}
})
return normalizedTools
}
/**
* Hook to fetch custom tools
*/
export function useCustomTools(workspaceId: string) {
return useQuery<CustomToolDefinition[]>({
queryKey: customToolsKeys.list(workspaceId),
queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal),
enabled: !!workspaceId,
staleTime: CUSTOM_TOOL_LIST_STALE_TIME, // 1 minute - tools don't change frequently
placeholderData: keepPreviousData,
})
}
/**
* Create custom tool mutation
*/
interface CreateCustomToolParams {
workspaceId: string
tool: {
title: string
schema: CustomToolSchema
code: string
}
}
export function useCreateCustomTool() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, tool }: CreateCustomToolParams) => {
logger.info(`Creating custom tool: ${tool.title} in workspace ${workspaceId}`)
const data = await requestJson(upsertCustomToolsContract, {
body: {
tools: [
{
title: tool.title,
schema: tool.schema,
code: tool.code,
},
],
workspaceId,
},
})
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid API response: missing tools data')
}
logger.info(`Created custom tool: ${tool.title}`)
return data.data as CustomToolDefinition[]
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
},
})
}
/**
* Update custom tool mutation
*/
interface UpdateCustomToolParams {
workspaceId: string
toolId: string
updates: {
title?: string
schema?: CustomToolSchema
code?: string
}
}
export function useUpdateCustomTool() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, toolId, updates }: UpdateCustomToolParams) => {
logger.info(`Updating custom tool: ${toolId} in workspace ${workspaceId}`)
const currentTools = queryClient.getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
)
const currentTool = currentTools?.find((t) => t.id === toolId)
if (!currentTool) {
throw new Error('Tool not found')
}
const data = await requestJson(upsertCustomToolsContract, {
body: {
tools: [
{
id: toolId,
title: updates.title ?? currentTool.title,
schema: updates.schema ?? currentTool.schema,
code: updates.code ?? currentTool.code,
},
],
workspaceId,
},
})
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid API response: missing tools data')
}
logger.info(`Updated custom tool: ${toolId}`)
return data.data as CustomToolDefinition[]
},
onMutate: async ({ workspaceId, toolId, updates }) => {
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
const previousTools = queryClient.getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
)
if (previousTools) {
queryClient.setQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId),
previousTools.map((tool) =>
tool.id === toolId
? {
...tool,
title: updates.title ?? tool.title,
schema: updates.schema ?? tool.schema,
code: updates.code ?? tool.code,
}
: tool
)
)
}
return { previousTools }
},
onError: (_err, variables, context) => {
if (context?.previousTools) {
queryClient.setQueryData(customToolsKeys.list(variables.workspaceId), context.previousTools)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
},
})
}
/**
* Delete custom tool mutation
*/
interface DeleteCustomToolParams {
workspaceId: string | null
toolId: string
}
export function useDeleteCustomTool() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, toolId }: DeleteCustomToolParams) => {
logger.info(`Deleting custom tool: ${toolId}`)
const data = await requestJson(deleteCustomToolContract, {
query: { id: toolId, workspaceId: workspaceId ?? undefined },
})
logger.info(`Deleted custom tool: ${toolId}`)
return data
},
onMutate: async ({ workspaceId, toolId }) => {
if (!workspaceId) return
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
const previousTools = queryClient.getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
)
if (previousTools) {
queryClient.setQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId),
previousTools.filter((tool) => tool.id !== toolId)
)
}
return { previousTools, workspaceId }
},
onError: (_err, _variables, context) => {
if (context?.previousTools && context?.workspaceId) {
queryClient.setQueryData(customToolsKeys.list(context.workspaceId), context.previousTools)
}
},
onSettled: (_data, _error, variables) => {
if (variables.workspaceId) {
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
}
},
})
}