AIAssistant.utils.ts158 lines · main
| 1 | import { isToolUIPart, type UIMessage } from 'ai' |
| 2 | import { toast } from 'sonner' |
| 3 | |
| 4 | import { SAFE_FUNCTIONS } from './AiAssistant.constants' |
| 5 | import { authKeys } from '@/data/auth/keys' |
| 6 | import { databaseExtensionsKeys } from '@/data/database-extensions/keys' |
| 7 | import { databaseIndexesKeys } from '@/data/database-indexes/keys' |
| 8 | import { databasePoliciesKeys } from '@/data/database-policies/keys' |
| 9 | import { databaseTriggerKeys } from '@/data/database-triggers/keys' |
| 10 | import { databaseKeys } from '@/data/database/keys' |
| 11 | import { enumeratedTypesKeys } from '@/data/enumerated-types/keys' |
| 12 | import { handleError } from '@/data/fetchers' |
| 13 | import { tableKeys } from '@/data/tables/keys' |
| 14 | import { tryParseJson } from '@/lib/helpers' |
| 15 | import { ResponseError } from '@/types' |
| 16 | |
| 17 | export type MutationCategory = 'functions' | 'rls-policies' |
| 18 | |
| 19 | // [Joshen] This is just very basic identification, but possible can extend perhaps |
| 20 | export const identifyQueryType = (query: string): MutationCategory | undefined => { |
| 21 | const formattedQuery = query.toLowerCase().replaceAll('\n', ' ') |
| 22 | if ( |
| 23 | formattedQuery.includes('create function') || |
| 24 | formattedQuery.includes('create or replace function') |
| 25 | ) { |
| 26 | return 'functions' |
| 27 | } else if (formattedQuery.includes('create policy') || formattedQuery.includes('alter policy')) { |
| 28 | return 'rls-policies' |
| 29 | } |
| 30 | return undefined |
| 31 | } |
| 32 | |
| 33 | // Check for function calls that aren't in the safe list |
| 34 | /** @deprecated [Joshen] Ideally we move away from this as this isn't a scalable way to deduce */ |
| 35 | export const containsUnknownFunction = (query: string) => { |
| 36 | const normalizedQuery = query.trim().toLowerCase() |
| 37 | const functionCallRegex = /\w+\s*\(/g |
| 38 | const functionCalls = normalizedQuery.match(functionCallRegex) || [] |
| 39 | |
| 40 | return functionCalls.some((func) => { |
| 41 | const isReadOnlyFunc = SAFE_FUNCTIONS.some((safeFunc) => func.trim().toLowerCase() === safeFunc) |
| 42 | return !isReadOnlyFunc |
| 43 | }) |
| 44 | } |
| 45 | |
| 46 | /** @deprecated |
| 47 | * [Joshen] This isn't really a scalable way to reduce this behaviour, we now have support |
| 48 | * for a readonly connection string which we can use this to run queries, and is a much |
| 49 | * clearer way to deduce if the query is read only or not |
| 50 | */ |
| 51 | export const isReadOnlySelect = (query: string): boolean => { |
| 52 | const normalizedQuery = query.trim().toLowerCase() |
| 53 | |
| 54 | // Check if it starts with SELECT |
| 55 | if (!normalizedQuery.startsWith('select')) return false |
| 56 | |
| 57 | // List of keywords that indicate write operations |
| 58 | const writeOperations = ['insert', 'update', 'delete', 'alter', 'drop', 'create', 'replace'] |
| 59 | |
| 60 | // Words that may appear in column names etc |
| 61 | const allowedPatterns = ['created', 'inserted', 'updated', 'deleted', 'truncate'] |
| 62 | |
| 63 | // Check for any write operations |
| 64 | const hasWriteOperation = writeOperations.some((op) => { |
| 65 | // Ignore if part of allowed pattern |
| 66 | const isAllowed = allowedPatterns.some( |
| 67 | (allowed) => normalizedQuery.includes(allowed) && allowed.includes(op) |
| 68 | ) |
| 69 | return !isAllowed && normalizedQuery.includes(op) |
| 70 | }) |
| 71 | if (hasWriteOperation) return false |
| 72 | |
| 73 | const hasUnknownFunction = containsUnknownFunction(normalizedQuery) |
| 74 | if (hasUnknownFunction) return false |
| 75 | |
| 76 | return true |
| 77 | } |
| 78 | |
| 79 | export const hasPendingToolApproval = (messages: Pick<UIMessage, 'role' | 'parts'>[]) => { |
| 80 | return messages.some((message) => { |
| 81 | if (message.role !== 'assistant') return false |
| 82 | |
| 83 | return message.parts?.some((part) => isToolUIPart(part) && part.state === 'approval-requested') |
| 84 | }) |
| 85 | } |
| 86 | |
| 87 | export const resolvePendingToolApprovalsAsDenied = (messages: UIMessage[]): UIMessage[] => { |
| 88 | return messages.map((message) => { |
| 89 | if (message.role !== 'assistant') return message |
| 90 | |
| 91 | const parts = message.parts?.map((part) => { |
| 92 | if (!isToolUIPart(part) || part.state !== 'approval-requested') return part |
| 93 | |
| 94 | return { |
| 95 | ...part, |
| 96 | state: 'output-denied', |
| 97 | approval: { |
| 98 | id: part.approval.id, |
| 99 | approved: false, |
| 100 | reason: 'Skipped because the user sent a follow-up message.', |
| 101 | }, |
| 102 | } as UIMessage['parts'][number] |
| 103 | }) |
| 104 | |
| 105 | return { ...message, parts } as UIMessage |
| 106 | }) |
| 107 | } |
| 108 | |
| 109 | const getContextKey = (pathname: string) => { |
| 110 | const [, , , ...rest] = pathname.split('/') |
| 111 | const key = rest.join('/') |
| 112 | return key |
| 113 | } |
| 114 | |
| 115 | export const getContextualInvalidationKeys = ({ |
| 116 | ref, |
| 117 | pathname, |
| 118 | schema = 'public', |
| 119 | }: { |
| 120 | ref: string |
| 121 | pathname: string |
| 122 | schema?: string |
| 123 | }) => { |
| 124 | const key = getContextKey(pathname) |
| 125 | |
| 126 | return ( |
| 127 | ( |
| 128 | { |
| 129 | 'auth/users': [authKeys.usersInfinite(ref)], |
| 130 | 'auth/policies': [databasePoliciesKeys.list(ref)], |
| 131 | 'database/functions': [databaseKeys.databaseFunctions(ref)], |
| 132 | 'database/tables': [tableKeys.list(ref, schema, true), tableKeys.list(ref, schema, false)], |
| 133 | 'database/triggers': [databaseTriggerKeys.list(ref)], |
| 134 | 'database/types': [enumeratedTypesKeys.list(ref)], |
| 135 | 'database/extensions': [databaseExtensionsKeys.list(ref)], |
| 136 | 'database/indexes': [databaseIndexesKeys.list(ref, schema)], |
| 137 | } as const |
| 138 | )[key] ?? [] |
| 139 | ) |
| 140 | } |
| 141 | |
| 142 | export const onErrorChat = (error: Error) => { |
| 143 | const parsedError = error ? tryParseJson(error.message) : undefined |
| 144 | |
| 145 | try { |
| 146 | handleError(parsedError?.error || parsedError || error) |
| 147 | } catch (e: any) { |
| 148 | if (e instanceof ResponseError) { |
| 149 | toast.error(e.message) |
| 150 | } else if (e instanceof Error) { |
| 151 | toast.error(e.message) |
| 152 | } else if (typeof e === 'string') { |
| 153 | toast.error(e) |
| 154 | } else { |
| 155 | toast.error('An unknown error occurred') |
| 156 | } |
| 157 | } |
| 158 | } |