Merge remote-tracking branch 'origin/master' into poc/generate-tables-using-ai

This commit is contained in:
Adria Navarro 2025-04-17 15:49:27 +02:00
commit ab47db5da0
12 changed files with 215 additions and 38 deletions

View File

@ -1,4 +1,4 @@
import { FieldType } from "@budibase/types"
import { FieldType, FormulaType } from "@budibase/types"
import { FIELDS } from "@/constants/backend"
import { tables } from "@/stores/builder"
import { get as svelteGet } from "svelte/store"
@ -8,7 +8,6 @@ import { makeReadableKeyPropSafe } from "@/dataBinding"
const MAX_DEPTH = 1
const TYPES_TO_SKIP = [
FieldType.FORMULA,
FieldType.AI,
FieldType.LONGFORM,
FieldType.SIGNATURE_SINGLE,
@ -17,6 +16,18 @@ const TYPES_TO_SKIP = [
FieldType.INTERNAL,
]
const shouldSkipFieldSchema = fieldSchema => {
// Skip some types always
if (TYPES_TO_SKIP.includes(fieldSchema.type)) {
return true
}
// Skip dynamic formula fields
return (
fieldSchema.type === FieldType.FORMULA &&
fieldSchema.formulaType === FormulaType.DYNAMIC
)
}
export function getBindings({
table,
path = null,
@ -32,7 +43,7 @@ export function getBindings({
// skip relationships after a certain depth and types which
// can't bind to
if (
TYPES_TO_SKIP.includes(schema.type) ||
shouldSkipFieldSchema(schema) ||
(isRelationship && depth >= MAX_DEPTH)
) {
continue

View File

@ -40,14 +40,27 @@
let switchOnAIModal: Modal
let addCreditsModal: Modal
const thresholdExpansionWidth = 350
$: accountPortalAccess = $auth?.user?.accountPortalAccess
$: accountPortal = $admin.accountPortalUrl
$: aiEnabled = $auth?.user?.llm
$: expanded = expandedOnly ? true : expanded
$: expanded =
expandedOnly ||
(parentWidth !== null && parentWidth > thresholdExpansionWidth)
? true
: expanded
$: creditsExceeded = $licensing.aiCreditsExceeded
$: disabled = suggestedCode !== null || !aiEnabled || creditsExceeded
$: if (expandedOnly) {
$: if (
expandedOnly ||
(expanded && parentWidth !== null && parentWidth > thresholdExpansionWidth)
) {
containerWidth = calculateExpandedWidth()
} else if (!expanded) {
containerWidth = "auto"
}
async function generateJs(prompt: string) {

View File

@ -443,7 +443,6 @@
const resizeObserver = new ResizeObserver(() => {
updateEditorWidth()
})
resizeObserver.observe(editorEle)
return () => {
resizeObserver.disconnect()
@ -469,7 +468,6 @@
{#if aiGenEnabled}
<AIGen
expandedOnly={true}
{bindings}
{value}
parentWidth={editorWidth}

View File

@ -1317,6 +1317,25 @@ const shouldReplaceBinding = (currentValue, from, convertTo, binding) => {
return !invalids.find(invalid => noSpaces?.includes(invalid))
}
// If converting readable to runtime we need to ensure we don't replace words
// which are substrings of other words - e.g. a binding of `a` would turn
// `hah` into `h[a]h` which is obviously wrong. To avoid this we can remove all
// expanded versions of the binding to be replaced.
const excludeReadableExtensions = (string, binding) => {
// Escape any special chars in the binding so we can treat it as a literal
// string match in the regexes below
const escaped = binding.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
// Regex to find prefixed bindings (e.g. exclude xfoo for foo)
const regex1 = new RegExp(`[a-zA-Z0-9-_]+${escaped}[a-zA-Z0-9-_]*`, "g")
// Regex to find prefixed bindings (e.g. exclude foox for foo)
const regex2 = new RegExp(`[a-zA-Z0-9-_]*${escaped}[a-zA-Z0-9-_]+`, "g")
const matches = [...string.matchAll(regex1), ...string.matchAll(regex2)]
for (const match of matches) {
string = string.replace(match[0], new Array(match[0].length + 1).join("*"))
}
return string
}
/**
* Utility function which replaces a string between given indices.
*/
@ -1361,6 +1380,11 @@ const bindingReplacement = (
// in the search, working from longest to shortest so always use best match first
let searchString = newBoundValue
for (let from of convertFromProps) {
// If converting readable > runtime, blank out all extensions of this
// string to avoid partial matches
if (convertTo === "runtimeBinding") {
searchString = excludeReadableExtensions(searchString, from)
}
const binding = bindableProperties.find(el => el[convertFrom] === from)
if (
isJS ||

View File

@ -72,6 +72,27 @@ describe("Builder dataBinding", () => {
runtimeBinding: "count",
type: "context",
},
{
category: "Bindings",
icon: "Brackets",
readableBinding: "location",
runtimeBinding: "[location]",
type: "context",
},
{
category: "Bindings",
icon: "Brackets",
readableBinding: "foo.[bar]",
runtimeBinding: "[foo].[qwe]",
type: "context",
},
{
category: "Bindings",
icon: "Brackets",
readableBinding: "foo.baz",
runtimeBinding: "[foo].[baz]",
type: "context",
},
]
it("should convert a readable binding to a runtime one", () => {
const textWithBindings = `Hello {{ Current User.firstName }}! The count is {{ Binding.count }}.`
@ -83,6 +104,28 @@ describe("Builder dataBinding", () => {
)
).toEqual(`Hello {{ [user].[firstName] }}! The count is {{ count }}.`)
})
it("should not convert a partial match", () => {
const textWithBindings = `location {{ _location Zlocation location locationZ _location_ }}`
expect(
readableToRuntimeBinding(
bindableProperties,
textWithBindings,
"runtimeBinding"
)
).toEqual(
`location {{ _location Zlocation [location] locationZ _location_ }}`
)
})
it("should handle special characters in the readable binding", () => {
const textWithBindings = `{{ foo.baz }}`
expect(
readableToRuntimeBinding(
bindableProperties,
textWithBindings,
"runtimeBinding"
)
).toEqual(`{{ [foo].[baz] }}`)
})
})
describe("updateReferencesInObject", () => {

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { Body, Label, Icon } from "@budibase/bbui"
import BudibaseLogo from "./logos/Budibase.svelte"
import OpenAILogo from "./logos/OpenAI.svelte"
@ -6,7 +6,7 @@
import TogetherAILogo from "./logos/TogetherAI.svelte"
import AzureOpenAILogo from "./logos/AzureOpenAI.svelte"
import { Providers } from "./constants"
import type { ProviderConfig } from "@budibase/types"
const logos = {
["Budibase AI"]: BudibaseLogo,
[Providers.OpenAI.name]: OpenAILogo,
@ -15,11 +15,11 @@
[Providers.AzureOpenAI.name]: AzureOpenAILogo,
}
export let config
export let disabled
export let config: ProviderConfig
export let disabled: boolean | null = null
export let editHandler
export let deleteHandler
export let editHandler: (() => void) | null
export let deleteHandler: (() => void) | null
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { onMount } from "svelte"
import {
Button,
@ -16,22 +16,23 @@
import { API } from "@/api"
import AIConfigModal from "./ConfigModal.svelte"
import AIConfigTile from "./AIConfigTile.svelte"
import {
type AIConfig,
ConfigType,
type ProviderConfig,
} from "@budibase/types"
const ConfigTypes = {
AI: "ai",
}
let modal
let fullAIConfig
let editingAIConfig = {}
let editingUuid
let modal: Modal
let fullAIConfig: AIConfig
let editingAIConfig: ProviderConfig | undefined
let editingUuid: string | undefined
$: isCloud = $admin.cloud
$: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
async function fetchAIConfig() {
try {
fullAIConfig = await API.getConfig(ConfigTypes.AI)
fullAIConfig = (await API.getConfig(ConfigType.AI)) as AIConfig
} catch (error) {
notifications.error("Error fetching AI config")
}
@ -42,9 +43,9 @@
const id = editingUuid || Helpers.uuid()
// Creating first custom AI Config
if (!fullAIConfig) {
if (!fullAIConfig && editingAIConfig) {
fullAIConfig = {
type: ConfigTypes.AI,
type: ConfigType.AI,
config: {
[id]: editingAIConfig,
},
@ -54,7 +55,7 @@
delete fullAIConfig.config.budibase_ai
// unset the default value from other configs if default is set
if (editingAIConfig.isDefault) {
if (editingAIConfig?.isDefault) {
for (let key in fullAIConfig.config) {
if (key !== id) {
fullAIConfig.config[key].isDefault = false
@ -62,8 +63,10 @@
}
}
// Add new or update existing custom AI Config
fullAIConfig.config[id] = editingAIConfig
fullAIConfig.type = ConfigTypes.AI
if (editingAIConfig) {
fullAIConfig.config[id] = editingAIConfig
}
fullAIConfig.type = ConfigType.AI
}
try {
@ -72,7 +75,7 @@
} catch (error) {
notifications.error(
`Failed to save AI Configuration, reason: ${
error?.message || "Unknown"
error instanceof Error ? error.message : "Unknown"
}`
)
} finally {
@ -80,7 +83,7 @@
}
}
async function deleteConfig(key) {
async function deleteConfig(key: string) {
// We don't store the default BB AI config in the DB
delete fullAIConfig.config.budibase_ai
// Delete the configuration
@ -91,14 +94,16 @@
notifications.success(`Deleted config`)
} catch (error) {
notifications.error(
`Failed to delete config, reason: ${error?.message || "Unknown"}`
`Failed to delete config, reason: ${
error instanceof Error ? error.message : "Unknown"
}`
)
} finally {
await fetchAIConfig()
}
}
function editConfig(uuid) {
function editConfig(uuid: string) {
editingUuid = uuid
editingAIConfig = fullAIConfig?.config[editingUuid]
modal.show()
@ -136,7 +141,10 @@
</Tags>
{/if}
</div>
<Body>Configure your AI settings within this section:</Body>
<Body
>Connect an LLM to enable AI features. You can only enable one LLM at a
time.</Body
>
</Layout>
<Divider />
<div style={`opacity: ${customAIConfigsEnabled ? 1 : 0.5}`}>

@ -1 +1 @@
Subproject commit 36fd5af9e2a6013e1bdf9c2458a438507152823b
Subproject commit 3efda0bc39ded6c43f7eb7492dcb287d92bf1475

View File

@ -1,3 +1,4 @@
import { z } from "zod"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import nock from "nock"
@ -10,12 +11,13 @@ import {
PlanModel,
PlanType,
ProviderConfig,
StructuredOutput,
} from "@budibase/types"
import { context } from "@budibase/backend-core"
import { mocks } from "@budibase/backend-core/tests"
import { generator, mocks } from "@budibase/backend-core/tests"
import { ai, quotas } from "@budibase/pro"
import { MockLLMResponseFn } from "../../../tests/utilities/mocks/ai"
import { mockAnthropicResponse } from "../../../tests/utilities/mocks/ai/anthropic"
import { quotas } from "@budibase/pro"
function dedent(str: string) {
return str
@ -285,7 +287,8 @@ describe("BudibaseAI", () => {
envCleanup()
})
beforeEach(() => {
beforeEach(async () => {
await config.newTenant()
nock.cleanAll()
const license: License = {
plan: {
@ -366,5 +369,66 @@ describe("BudibaseAI", () => {
}
)
})
it("handles text format", async () => {
let usage = await getQuotaUsage()
expect(usage._id).toBe(`quota_usage_${config.getTenantId()}`)
expect(usage.monthly.current.budibaseAICredits).toBe(0)
const gptResponse = generator.word()
mockChatGPTResponse(gptResponse, { format: "text" })
const { message } = await config.api.ai.chat({
messages: [{ role: "user", content: "Hello!" }],
format: "text",
licenseKey: licenseKey,
})
expect(message).toBe(gptResponse)
usage = await getQuotaUsage()
expect(usage.monthly.current.budibaseAICredits).toBeGreaterThan(0)
})
it("handles json format", async () => {
let usage = await getQuotaUsage()
expect(usage._id).toBe(`quota_usage_${config.getTenantId()}`)
expect(usage.monthly.current.budibaseAICredits).toBe(0)
const gptResponse = JSON.stringify({
[generator.word()]: generator.word(),
})
mockChatGPTResponse(gptResponse, { format: "json" })
const { message } = await config.api.ai.chat({
messages: [{ role: "user", content: "Hello!" }],
format: "json",
licenseKey: licenseKey,
})
expect(message).toBe(gptResponse)
usage = await getQuotaUsage()
expect(usage.monthly.current.budibaseAICredits).toBeGreaterThan(0)
})
it("handles structured outputs", async () => {
let usage = await getQuotaUsage()
expect(usage._id).toBe(`quota_usage_${config.getTenantId()}`)
expect(usage.monthly.current.budibaseAICredits).toBe(0)
const gptResponse = generator.guid()
const structuredOutput = generator.word() as unknown as StructuredOutput
ai.structuredOutputs[structuredOutput] = {
key: generator.word(),
validator: z.object({ name: z.string() }),
}
mockChatGPTResponse(gptResponse, { format: structuredOutput })
const { message } = await config.api.ai.chat({
messages: [{ role: "user", content: "Hello!" }],
format: structuredOutput,
licenseKey: licenseKey,
})
expect(message).toBe(gptResponse)
usage = await getQuotaUsage()
expect(usage.monthly.current.budibaseAICredits).toBeGreaterThan(0)
})
})
})

View File

@ -1,7 +1,9 @@
import { ResponseFormat } from "@budibase/types"
import { Scope } from "nock"
export interface MockLLMResponseOpts {
host?: string
format?: ResponseFormat
}
export type MockLLMResponseFn = (

View File

@ -1,5 +1,7 @@
import nock from "nock"
import { MockLLMResponseFn, MockLLMResponseOpts } from "."
import _ from "lodash"
import { ai } from "@budibase/pro"
let chatID = 1
const SPACE_REGEX = /\s+/g
@ -48,8 +50,15 @@ export const mockChatGPTResponse: MockLLMResponseFn = (
answer: string | ((prompt: string) => string),
opts?: MockLLMResponseOpts
) => {
let body: any = undefined
if (opts?.format) {
body = _.matches({
response_format: ai.openai.parseResponseFormat(opts.format),
})
}
return nock(opts?.host || "https://api.openai.com")
.post("/v1/chat/completions")
.post("/v1/chat/completions", body)
.reply((uri: string, body: nock.Body) => {
const req = body as ChatCompletionRequest
const messages = req.messages

View File

@ -5,8 +5,13 @@ export interface Message {
content: string
}
export enum StructuredOutput {}
export type ResponseFormat = "text" | "json" | StructuredOutput
export interface ChatCompletionRequest {
messages: Message[]
format?: ResponseFormat
}
export interface ChatCompletionResponse {