Merge remote-tracking branch 'origin/master' into poc/generate-tables-using-ai
This commit is contained in:
commit
ab47db5da0
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { ResponseFormat } from "@budibase/types"
|
||||
import { Scope } from "nock"
|
||||
|
||||
export interface MockLLMResponseOpts {
|
||||
host?: string
|
||||
format?: ResponseFormat
|
||||
}
|
||||
|
||||
export type MockLLMResponseFn = (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue