Merge branch 'master' of github.com:Budibase/budibase into remove-tour

This commit is contained in:
Andrew Kingston 2025-05-02 07:41:24 +01:00
commit 1f2ed51ca3
No known key found for this signature in database
91 changed files with 2328 additions and 919 deletions

View File

@ -239,6 +239,7 @@ jobs:
- name: Test server
env:
DATASOURCE: ${{ matrix.datasource }}
# DEBUG: testcontainers*
run: |
if ${{ env.ONLY_AFFECTED_TASKS }}; then
AFFECTED=$(yarn --silent nx show projects --affected -t test --base=${{ env.NX_BASE_BRANCH }} -p @budibase/server)

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.9.3",
"version": "3.9.5",
"npmClient": "yarn",
"concurrency": 20,
"command": {

View File

@ -9,6 +9,7 @@ export enum Cookie {
ACCOUNT_RETURN_URL = "budibase:account:returnurl",
DatasourceAuth = "budibase:datasourceauth",
OIDC_CONFIG = "budibase:oidc:config",
FeatureFlags = "budibase:featureflags",
}
export { Header } from "@budibase/shared-core"

View File

@ -460,6 +460,17 @@ export function setFeatureFlags(key: string, value: Record<string, boolean>) {
context.featureFlagCache[key] = value
}
export function getFeatureFlagOverrides(): Record<string, boolean> {
return getCurrentContext()?.featureFlagOverrides || {}
}
export async function doInFeatureFlagOverrideContext<T>(
value: Record<string, boolean>,
callback: () => Promise<T>
) {
return await newContext({ featureFlagOverrides: value }, callback)
}
export function getTableForView(viewId: string): Table | undefined {
const context = getCurrentContext()
if (!context) {

View File

@ -24,5 +24,6 @@ export type ContextMap = {
featureFlagCache?: {
[key: string]: Record<string, boolean>
}
featureFlagOverrides?: Record<string, boolean>
viewToTableCache?: Record<string, Table>
}

View File

@ -1,5 +1,7 @@
// BASE
import { ErrorCode } from "@budibase/types"
export abstract class BudibaseError extends Error {
code: string
@ -13,13 +15,6 @@ export abstract class BudibaseError extends Error {
// ERROR HANDLING
export enum ErrorCode {
USAGE_LIMIT_EXCEEDED = "usage_limit_exceeded",
FEATURE_DISABLED = "feature_disabled",
INVALID_API_KEY = "invalid_api_key",
HTTP = "http",
}
/**
* For the given error, build the public representation that is safe
* to be exposed over an api.

View File

@ -175,6 +175,21 @@ export class FlagSet<T extends { [name: string]: boolean }> {
}
}
const overrides = context.getFeatureFlagOverrides()
for (const [key, value] of Object.entries(overrides)) {
if (!this.isFlagName(key)) {
continue
}
if (typeof value !== "boolean") {
continue
}
// @ts-expect-error - TS does not like you writing into a generic type.
flagValues[key] = value
tags[`flags.${key}.source`] = "override"
}
context.setFeatureFlags(this.setId, flagValues)
for (const [key, value] of Object.entries(flagValues)) {
tags[`flags.${key}.value`] = value

View File

@ -16,11 +16,12 @@ import env from "../environment"
import {
Ctx,
EndpointMatcher,
ErrorCode,
LoginMethod,
SessionCookie,
User,
} from "@budibase/types"
import { ErrorCode, InvalidAPIKeyError } from "../errors"
import { InvalidAPIKeyError } from "../errors"
import tracer from "dd-trace"
import type { Middleware, Next } from "koa"

View File

@ -0,0 +1,13 @@
import { Ctx, FeatureFlagCookie } from "@budibase/types"
import { Middleware, Next } from "koa"
import { getCookie } from "../utils"
import { Cookie } from "../constants"
import { doInFeatureFlagOverrideContext } from "../context"
export default (async (ctx: Ctx, next: Next) => {
const cookie = getCookie<FeatureFlagCookie>(ctx, Cookie.FeatureFlags)
const flags = cookie?.flags || {}
await doInFeatureFlagOverrideContext(flags, async () => {
await next()
})
}) as Middleware

View File

@ -20,5 +20,6 @@ export { default as correlation } from "../logging/correlation/middleware"
export { default as errorHandling } from "./errorHandling"
export { default as querystringToBody } from "./querystringToBody"
export { default as csp } from "./contentSecurityPolicy"
export { default as featureFlagCookie } from "./featureFlagCookie"
export * as joiValidator from "./joi-validator"
export { default as ip } from "./ip"

View File

@ -28,7 +28,6 @@
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { datasources, tables } from "@/stores/builder"
import { licensing } from "@/stores/portal"
import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants"
import {
DB_TYPE_EXTERNAL,
@ -137,8 +136,6 @@
]
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: aiEnabled =
$licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
$: if (hasPrimaryDisplay && editableColumn.constraints) {
editableColumn.constraints.presence = { allowEmpty: false }
}
@ -559,10 +556,8 @@
FIELDS.SIGNATURE_SINGLE,
FIELDS.JSON,
FIELDS.AUTO,
FIELDS.AI,
]
if (aiEnabled) {
fields.push(FIELDS.AI)
}
return fields
}
if (isExternalTable && isSqlTable) {

View File

@ -1,4 +1,5 @@
import { TableNames } from "@/constants"
import { INTERNAL_TABLE_SOURCE_ID } from "@budibase/types"
const showDatasourceOpen = ({
selected,
@ -41,7 +42,7 @@ const containsActiveEntity = (
// Check for hardcoded datasource edge cases
if (
isActive("./datasource/bb_internal") &&
datasource._id === "bb_internal"
datasource._id === INTERNAL_TABLE_SOURCE_ID
) {
return true
}

View File

@ -8,7 +8,7 @@
notifications,
} from "@budibase/bbui"
import { API } from "@/api"
import { goto } from "@roxi/routify"
import { goto, params, isActive } from "@roxi/routify"
import {
automationStore,
previewStore,
@ -19,54 +19,36 @@
queries,
tables,
views,
viewsV2,
} from "@/stores/builder"
import { themeStore } from "@/stores/portal"
import { themeStore, featureFlags } from "@/stores/portal"
import { getContext } from "svelte"
import { ThemeOptions } from "@budibase/shared-core"
import { FeatureFlag } from "@budibase/types"
const modalContext = getContext(Context.Modal)
const commands = [
let search
let selected = null
$: inApp = $isActive("/builder/app/:application")
$: commands = [
{
type: "Access",
name: "Invite users and manage app access",
description: "",
icon: "User",
action: () => builderStore.showBuilderSidePanel(),
requiresApp: true,
},
{
type: "Navigate",
name: "Portal",
description: "",
icon: "Compass",
action: () => $goto("../../portal"),
},
{
type: "Navigate",
name: "Data",
description: "",
icon: "Compass",
action: () => $goto("./data"),
},
{
type: "Navigate",
name: "Design",
description: "",
icon: "Compass",
action: () => $goto("./design"),
},
{
type: "Navigate",
name: "Automations",
description: "",
icon: "Compass",
action: () => $goto("./automation"),
},
...navigationCommands(),
{
type: "Publish",
name: "App",
description: "Deploy your application",
icon: "Box",
action: deployApp,
requiresApp: true,
},
{
type: "Preview",
@ -74,12 +56,14 @@
description: "",
icon: "Play",
action: () => previewStore.showPreview(true),
requiresApp: true,
},
{
type: "Preview",
name: "Published App",
icon: "Play",
action: () => window.open(`/app${$appStore.url}`),
requiresApp: true,
},
{
type: "Support",
@ -87,6 +71,7 @@
icon: "Help",
action: () =>
window.open(`https://github.com/Budibase/budibase/discussions/new`),
requiresApp: true,
},
{
type: "Support",
@ -96,52 +81,166 @@
window.open(
`https://github.com/Budibase/budibase/issues/new?assignees=&labels=bug&template=bug_report.md&title=`
),
requiresApp: true,
},
...($datasources?.list?.map(datasource => ({
...datasourceCommands($datasources?.list || []),
...tableCommands($tables?.list || []),
...viewCommands($views?.list || []),
...viewV2Commands($viewsV2?.list || []),
...queryCommands($queries?.list || []),
...screenCommands($sortedScreens),
...automationCommands($automationStore?.automations || []),
...themeCommands(),
...featureFlagCommands($featureFlags),
]
$: enrichedCommands = commands.map(cmd => ({
...cmd,
searchValue: `${cmd.type} ${cmd.name}`.toLowerCase().replace(/_/g, " "),
}))
$: results = filterResults(enrichedCommands, search, inApp)
$: categories = groupResults(results)
const navigationCommands = () => {
const routes = [
{
name: "Portal",
url: "/builder/portal",
},
{
name: "Data",
url: "/builder/app/:application/data",
},
{
name: "Design",
url: "/builder/app/:application/design",
},
{
name: "Automations",
url: "/builder/app/:application/automation",
},
{
name: "Settings",
url: "/builder/app/:application/settings",
},
]
return routes.map(route => ({
type: "Navigate",
name: route.name,
icon: "Compass",
action: () => {
const gotoParams = route.url.includes(":application")
? { application: $params.application }
: {}
$goto(route.url, gotoParams)
},
requiresApp: true,
}))
}
const datasourceCommands = datasources => {
return datasources.map(datasource => ({
type: "Datasource",
name: `${datasource.name}`,
name: datasource.name,
icon: "Data",
action: () => $goto(`./data/datasource/${datasource._id}`),
})) ?? []),
...($tables?.list?.map(table => ({
action: () =>
$goto(`/builder/app/:application/data/datasource/:id`, {
application: $params.application,
id: datasource._id,
}),
requiresApp: true,
}))
}
const tableCommands = tables => {
return tables.map(table => ({
type: "Table",
name: table.name,
icon: "Table",
action: () => $goto(`./data/table/${table._id}`),
})) ?? []),
...($views?.list?.map(view => ({
action: () =>
$goto(`/builder/app/:application/data/table/:id`, {
application: $params.application,
id: table._id,
}),
requiresApp: true,
}))
}
const viewCommands = views => {
return views.map(view => ({
type: "View",
name: view.name,
icon: "Remove",
action: () => {
if (view.version === 2) {
$goto(`./data/view/v2/${view.id}`)
} else {
$goto(`./data/view/${view.name}`)
}
$goto(`/builder/app/:application/data/view/:name`, {
application: $params.application,
name: view.name,
})
},
})) ?? []),
...($queries?.list?.map(query => ({
requiresApp: true,
}))
}
const viewV2Commands = views => {
return views.map(view => ({
type: "View",
name: view.name,
icon: "Remove",
action: () => {
$goto(`/builder/app/:application/data/table/:tableId/:viewId`, {
application: $params.application,
x: view.tableId,
viewId: view.id,
})
},
requiresApp: true,
}))
}
const queryCommands = queries => {
return queries.map(query => ({
type: "Query",
name: query.name,
icon: "SQLQuery",
action: () => $goto(`./data/query/${query._id}`),
})) ?? []),
...$sortedScreens.map(screen => ({
action: () =>
$goto(`/builder/app/:application/data/query/:id`, {
application: $params.application,
id: query._id,
}),
requiresApp: true,
}))
}
const screenCommands = screens => {
return screens.map(screen => ({
type: "Screen",
name: screen.routing.route,
icon: "WebPage",
action: () => {
$goto(`./design/${screen._id}/${screen._id}-screen`)
},
})),
...($automationStore?.automations?.map(automation => ({
action: () =>
$goto(`/builder/app/:application/design/:screenId/:componentId`, {
application: $params.application,
screenId: screen._id,
componentId: `${screen._id}-screen`,
}),
requiresApp: true,
}))
}
const automationCommands = automations => {
return automations.map(automation => ({
type: "Automation",
name: automation.name,
icon: "ShareAndroid",
action: () => $goto(`./automation/${automation._id}`),
})) ?? []),
...ThemeOptions.map(themeMeta => ({
action: () =>
$goto(`/builder/app/:application/automation/:id`, {
application: $params.application,
id: automation._id,
}),
requiresApp: true,
}))
}
const themeCommands = () => {
return ThemeOptions.map(themeMeta => ({
type: "Change Builder Theme",
name: themeMeta.name,
icon: "ColorPalette",
@ -150,28 +249,41 @@
state.theme = themeMeta.id
return state
}),
})),
]
}))
}
let search
let selected = null
$: enrichedCommands = commands.map(cmd => ({
...cmd,
searchValue: `${cmd.type} ${cmd.name}`.toLowerCase(),
}))
$: results = filterResults(enrichedCommands, search)
$: categories = groupResults(results)
const filterResults = (commands, search) => {
if (!search) {
selected = null
return commands
const featureFlagCommands = flags => {
if (!flags.DEBUG_UI) {
return []
}
return Object.entries(flags)
.filter(([flag]) => flag !== FeatureFlag.DEBUG_UI)
.map(([flag, value]) => ({
type: "Feature Flag",
name: `${value ? "Disable" : "Enable"} <code>${flag}</code>`,
icon: "Flag",
action: () => {
featureFlags.setFlag(flag, !value)
},
}))
}
const filterResults = (commands, search, inApp) => {
if (search) {
selected = 0
search = search.toLowerCase().replace(/_/g, " ")
} else {
selected = null
}
selected = 0
search = search.toLowerCase()
return commands
.filter(cmd => cmd.searchValue.includes(search))
.filter(cmd => {
// Handle searching
if (search && !cmd.searchValue.includes(search)) {
return false
}
// Handle commands that require an app
return inApp || !cmd.requiresApp
})
.map((cmd, idx) => ({
...cmd,
idx,
@ -264,7 +376,8 @@
<Icon size="M" name={command.icon} />
<strong>{command.type}:&nbsp;</strong>
<div class="name">
{command.name}
<!--eslint-disable-next-line svelte/no-at-html-tags-->
{@html command.name}
</div>
</div>
{/each}
@ -339,4 +452,10 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.name :global(code) {
font-size: 12px;
background: var(--background-alt);
padding: 4px;
border-radius: 4px;
}
</style>

View File

@ -1,9 +1,9 @@
<script lang="ts">
import { ActionButton, notifications } from "@budibase/bbui"
import { Button, notifications } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { API } from "@/api"
import type { EnrichedBinding } from "@budibase/types"
import { ErrorCode, type EnrichedBinding } from "@budibase/types"
import analytics, { Events } from "@/analytics"
import AiInput from "../ai/AIInput.svelte"
@ -22,6 +22,7 @@
let previousContents: string | null = null
let expanded = false
let promptText = ""
let inputValue = ""
const thresholdExpansionWidth = 350
@ -31,8 +32,6 @@
? true
: expanded
$: containerWidth = expanded ? calculateExpandedWidth() : "auto"
async function generateJs(prompt: string) {
promptText = ""
if (!prompt.trim()) return
@ -43,17 +42,24 @@
const resp = await API.generateJs({ prompt, bindings })
const code = resp.code
if (code === "") {
throw new Error("We didn't understand your prompt. Please rephrase it.")
throw new Error(
"We didn't understand your prompt. This can happen if the prompt isn't specific, or if it's a request for something other than code. Try expressing your request in a different way."
)
}
suggestedCode = code
dispatch("update", { code })
} catch (e) {
} catch (e: any) {
console.error(e)
notifications.error(
e instanceof Error
? `Unable to generate code: ${e.message}`
: "Unable to generate code. Please try again later."
)
if ("code" in e && e.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
notifications.error(
"Monthly usage limit reached. We're exploring options to expand this soon. Questions? Contact support@budibase.com"
)
} else if ("message" in e) {
notifications.error(`Unable to generate code: ${e.message}`)
} else {
notifications.error(`Unable to generate code.`)
}
}
}
@ -78,24 +84,21 @@
function reset() {
suggestedCode = null
previousContents = null
}
function calculateExpandedWidth() {
return parentWidth
? `${Math.min(Math.max(parentWidth * 0.8, 300), 600)}px`
: "300px"
promptText = ""
expanded = false
inputValue = ""
}
</script>
<div class="ai-gen-container" style="--container-width: {containerWidth}">
<div class="ai-gen-container" class:expanded>
{#if suggestedCode !== null}
<div class="floating-actions">
<ActionButton size="S" icon="CheckmarkCircle" on:click={acceptSuggestion}>
<Button cta size="S" icon="CheckmarkCircle" on:click={acceptSuggestion}>
Accept
</ActionButton>
<ActionButton size="S" icon="Delete" on:click={rejectSuggestion}>
Reject
</ActionButton>
</Button>
<Button primary size="S" icon="Delete" on:click={rejectSuggestion}
>Reject</Button
>
</div>
{/if}
@ -103,7 +106,7 @@
placeholder="Generate with AI"
onSubmit={generateJs}
bind:expanded
on:collapse={rejectSuggestion}
bind:value={inputValue}
readonly={!!suggestedCode}
{expandedOnly}
/>
@ -112,32 +115,27 @@
<style>
.ai-gen-container {
height: 40px;
--container-width: auto;
position: absolute;
right: 10px;
bottom: 10px;
width: var(--container-width);
bottom: var(--spacing-s);
right: var(--spacing-s);
display: flex;
overflow: visible;
transition: width 0.4s cubic-bezier(0.19, 1, 0.22, 1);
width: 22ch;
}
@keyframes border-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
.ai-gen-container.expanded {
width: calc(100% - var(--spacing-s) * 2);
}
.floating-actions {
position: absolute;
display: flex;
gap: var(--spacing-s);
bottom: calc(100% + 5px);
left: 5px;
bottom: calc(100% + var(--spacing-s));
z-index: 2;
animation: fade-in 0.2s ease-out forwards;
width: 100%;
}
@keyframes fade-in {

View File

@ -9,7 +9,6 @@
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
import {
autocompletion,
closeBrackets,
@ -52,17 +51,12 @@
import type { Extension } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./"
import { themeStore } from "@/stores/portal"
import {
type EnrichedBinding,
FeatureFlag,
type EditorMode,
} from "@budibase/types"
import { featureFlags, themeStore } from "@/stores/portal"
import { type EnrichedBinding, type EditorMode } from "@budibase/types"
import { tooltips } from "@codemirror/view"
import type { BindingCompletion, CodeValidator } from "@/types"
import { validateHbsTemplate } from "./validator/hbs"
import { validateJsTemplate } from "./validator/js"
import { featureFlag } from "@/helpers"
import AIGen from "./AIGen.svelte"
export let label: string | undefined = undefined
@ -88,6 +82,7 @@
let isEditorInitialised = false
let queuedRefresh = false
let editorWidth: number | null = null
let isAIGeneratedContent = false
// Theming!
let currentTheme = $themeStore?.theme
@ -101,9 +96,7 @@
}
$: aiGenEnabled =
featureFlag.isEnabled(FeatureFlag.AI_JS_GENERATION) &&
mode.name === "javascript" &&
!readonly
$featureFlags.AI_JS_GENERATION && mode.name === "javascript" && !readonly
$: {
if (autofocus && isEditorInitialised) {
@ -422,13 +415,15 @@
})
}
// Handle AI generation code updates
const handleAICodeUpdate = (event: CustomEvent<{ code: string }>) => {
const { code } = event.detail
value = code
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code },
})
isAIGeneratedContent = true
dispatch("change", code)
dispatch("ai_suggestion")
}
onMount(() => {
@ -462,7 +457,12 @@
</div>
{/if}
<div class={`code-editor ${mode?.name || ""}`} bind:this={editorEle}>
<div
class={`code-editor ${mode?.name || ""} ${
isAIGeneratedContent ? "ai-generated" : ""
}`}
bind:this={editorEle}
>
<div tabindex="-1" bind:this={textarea} />
</div>
@ -475,6 +475,7 @@
on:accept={() => {
dispatch("change", editor.state.doc.toString())
dispatch("blur", editor.state.doc.toString())
isAIGeneratedContent = false
}}
on:reject={event => {
const { code } = event.detail
@ -482,6 +483,8 @@
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code || "" },
})
isAIGeneratedContent = false
dispatch("change", code || "")
}}
/>
{/if}
@ -691,4 +694,19 @@
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.code-editor.ai-generated :global(.cm-editor) {
background: var(--spectrum-global-color-blue-50) !important;
}
.code-editor.ai-generated :global(.cm-content) {
background: transparent !important;
}
.code-editor.ai-generated :global(.cm-line) {
background: #765ffe1a !important;
display: inline-block;
min-width: fit-content;
padding-right: 2px !important;
}
</style>

View File

@ -28,6 +28,7 @@
$: isPremiumOrAbove = [
Constants.PlanType.PREMIUM,
Constants.PlanType.PREMIUM_PLUS,
Constants.PlanType.ENTERPRISE,
Constants.PlanType.ENTERPRISE_BASIC_TRIAL,
Constants.PlanType.ENTERPRISE_BASIC,

View File

@ -14,7 +14,6 @@
export const submit = onPromptSubmit
$: expanded = expandedOnly || expanded
const dispatch = createEventDispatcher()
let promptInput: HTMLInputElement
@ -28,9 +27,11 @@
$: aiEnabled = $auth?.user?.llm
$: creditsExceeded = $licensing.aiCreditsExceeded
$: disabled = !aiEnabled || creditsExceeded || readonly || promptLoading
$: disabled = !aiEnabled || creditsExceeded || readonly
$: animateBorder = !disabled && expanded
$: canSubmit = !readonly && !!value
function collapse() {
dispatch("collapse")
expanded = expandedOnly
@ -62,7 +63,7 @@
}
async function onPromptSubmit() {
if (readonly) {
if (!canSubmit) {
return
}
promptLoading = true
@ -89,6 +90,7 @@
src={BBAI}
alt="AI"
class="ai-icon"
class:loading={promptLoading}
class:disabled={expanded && disabled}
on:click={e => {
e.stopPropagation()
@ -103,7 +105,7 @@
class="prompt-input"
{placeholder}
on:keydown={handleKeyPress}
{disabled}
disabled={disabled || promptLoading}
/>
{:else}
<span class="spectrum-ActionButton-label ai-gen-text">
@ -151,13 +153,14 @@
</Modal>
{:else}
<Icon
color={promptLoading
color={promptInput
? "#6E56FF"
: "var(--spectrum-global-color-gray-600)"}
size="S"
disabled={!canSubmit || promptLoading}
hoverable={!readonly}
hoverColor="#6E56FF"
name={promptLoading ? "StopCircle" : "PlayCircle"}
name={"PlayCircle"}
on:click={onPromptSubmit}
/>
{/if}
@ -167,14 +170,12 @@
<style>
.spectrum-ActionButton {
--offset: 1px;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: var(--spacing-s);
border: 1px solid var(--spectrum-alias-border-color);
border-radius: 30px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
@ -182,16 +183,30 @@
overflow: hidden;
cursor: pointer;
background-color: var(--spectrum-global-color-gray-75);
border: none;
}
.spectrum-ActionButton::before {
content: "";
position: absolute;
top: -1px;
left: -1px;
width: calc(100% + 2px);
height: calc(100% + 2px);
border-radius: inherit;
inset: 0;
border-radius: 30px;
padding: 1px;
background: var(--spectrum-alias-border-color);
-webkit-mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.animate-border::after {
content: "";
position: absolute;
inset: 0;
border-radius: 30px;
padding: 1px;
background: linear-gradient(
125deg,
transparent -10%,
@ -201,40 +216,42 @@
transparent 35%,
transparent 110%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 0;
animation: border-flow 1.5s cubic-bezier(0.17, 0.67, 0.83, 0.67) forwards;
}
.spectrum-ActionButton:not(.animate-border)::before {
@keyframes border-flow {
0% {
clip-path: polygon(0% 0%, 10% 0%, 8% 100%, 0% 100%);
}
30% {
clip-path: polygon(0% 0%, 35% 0%, 26% 100%, 0% 100%);
}
50% {
clip-path: polygon(0% 0%, 55% 0%, 41% 100%, 0% 100%);
}
70% {
clip-path: polygon(0% 0%, 70% 0%, 53% 100%, 0% 100%);
}
85% {
clip-path: polygon(0% 0%, 80% 0%, 60% 100%, 0% 100%);
}
95% {
clip-path: polygon(0% 0%, 86% 0%, 65% 100%, 0% 100%);
}
100% {
clip-path: polygon(0% 0%, 90% 0%, 68% 100%, 0% 100%);
}
}
.spectrum-ActionButton:not(.animate-border)::after {
content: none;
}
.animate-border::before {
animation: border-fade-in 1s cubic-bezier(0.17, 0.67, 0.83, 0.67);
animation-fill-mode: forwards;
}
@keyframes border-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.spectrum-ActionButton::after {
content: "";
background: inherit;
position: absolute;
top: 50%;
left: 50%;
inset: var(--offset);
height: calc(100% - 2 * var(--offset));
width: calc(100% - 2 * var(--offset));
border-radius: inherit;
}
@keyframes fade-in {
from {
opacity: 0;
@ -265,9 +282,12 @@
.ai-icon {
width: 18px;
height: 18px;
margin-right: 8px;
margin-left: var(--spacing-xs);
margin-right: var(--spacing-s);
flex-shrink: 0;
cursor: var(--ai-icon-cursor, pointer);
position: relative;
z-index: 2;
}
.ai-gen-text {
@ -275,11 +295,13 @@
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.2s ease-out;
margin-right: var(--spacing-xs);
margin: auto;
position: relative;
z-index: 2;
}
.prompt-input {
font-size: 14px;
font-size: inherit;
flex: 1;
border: none;
background: transparent;
@ -289,6 +311,8 @@
min-width: 0;
resize: none;
overflow: hidden;
position: relative;
z-index: 2;
}
.prompt-input::placeholder {
@ -299,14 +323,15 @@
.action-buttons {
display: flex;
gap: var(--spacing-s);
z-index: 4;
z-index: 5;
flex-shrink: 0;
margin-right: var(--spacing-s);
position: relative;
}
.button-content-wrapper {
position: relative;
z-index: 1;
z-index: 2;
display: flex;
align-items: center;
overflow: hidden;
@ -324,4 +349,17 @@
filter: grayscale(1) brightness(1.5);
opacity: 0.5;
}
.ai-icon.loading {
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -372,6 +372,7 @@
<CodeEditor
value={jsValue ? decodeJSBinding(jsValue) : ""}
on:change={onChangeJSValue}
on:ai_suggestion={() => (sidePanel = "Evaluation")}
{completions}
{bindings}
{validations}

View File

@ -1,113 +1,5 @@
/**
* Duplicates a name with respect to a collection of existing names
* e.g.
* name all names result
* ------ ----------- --------
* ("foo") ["foo"] "foo 1"
* ("foo") ["foo", "foo 1"] "foo 2"
* ("foo 1") ["foo", "foo 1"] "foo 2"
* ("foo") ["foo", "foo 2"] "foo 1"
*
* Repl
*/
export const duplicateName = (name: string, allNames: string[]) => {
const duplicatePattern = new RegExp(`\\s(\\d+)$`)
const baseName = name.split(duplicatePattern)[0]
const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`)
// export * from
import { helpers } from "@budibase/shared-core"
// get the sequence from matched names
const sequence: number[] = []
allNames.filter(n => {
if (n === baseName) {
return true
}
const match = n.match(isDuplicate)
if (match) {
sequence.push(parseInt(match[1]))
return true
}
return false
})
sequence.sort((a, b) => a - b)
// get the next number in the sequence
let number
if (sequence.length === 0) {
number = 1
} else {
// get the next number in the sequence
for (let i = 0; i < sequence.length; i++) {
if (sequence[i] !== i + 1) {
number = i + 1
break
}
}
if (!number) {
number = sequence.length + 1
}
}
return `${baseName} ${number}`
}
/**
* More flexible alternative to the above function, which handles getting the
* next sequential name from an array of existing items while accounting for
* any type of prefix, and being able to deeply retrieve that name from the
* existing item array.
*
* Examples with a prefix of "foo":
* [] => "foo"
* ["foo"] => "foo2"
* ["foo", "foo6"] => "foo7"
*
* Examples with a prefix of "foo " (space at the end):
* [] => "foo"
* ["foo"] => "foo 2"
* ["foo", "foo 6"] => "foo 7"
*
* @param items the array of existing items
* @param prefix the string prefix of each name, including any spaces desired
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = <T extends any>(
items: T[] | null,
prefix: string | null,
{
getName,
numberFirstItem,
separator = "",
}: {
getName?: (item: T) => string
numberFirstItem?: boolean
separator?: string
} = {}
) => {
if (!prefix?.length) {
return ""
}
const trimmedPrefix = prefix.trim()
const firstName = numberFirstItem ? `${prefix}1` : trimmedPrefix
if (!items?.length) {
return firstName
}
let max = 0
items.forEach(item => {
const name = getName?.(item) ?? item
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}
const split = name.split(trimmedPrefix)
if (split.length !== 2) {
return
}
if (split[1].trim() === "") {
split[1] = "1"
}
const num = parseInt(split[1])
if (num > max) {
max = num
}
})
return max === 0 ? firstName : `${prefix}${separator}${max + 1}`
}
export const duplicateName = helpers.duplicateName
export const getSequentialName = helpers.getSequentialName

View File

@ -6,8 +6,11 @@
import { API } from "@/api"
import Branding from "./Branding.svelte"
import ContextMenu from "@/components/ContextMenu.svelte"
import CommandPalette from "@/components/commandPalette/CommandPalette.svelte"
import { Modal } from "@budibase/bbui"
let loaded = false
let commandPaletteModal
$: multiTenancyEnabled = $admin.multiTenancy
$: hasAdminUser = $admin?.checklist?.adminUser?.checked
@ -157,12 +160,25 @@
}
}
}
// Event handler for the command palette
const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
commandPaletteModal.toggle()
}
}
</script>
<!--Portal branding overrides -->
<Branding />
<ContextMenu />
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette />
</Modal>
{#if loaded}
<slot />
{/if}

View File

@ -14,7 +14,6 @@
Tabs,
Tab,
Heading,
Modal,
notifications,
TooltipPosition,
} from "@budibase/bbui"
@ -24,7 +23,6 @@
import { capitalise } from "@/helpers"
import { onMount, onDestroy } from "svelte"
import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte"
import CommandPalette from "@/components/commandPalette/CommandPalette.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import { UserAvatars } from "@budibase/frontend-core"
import PreviewOverlay from "./_components/PreviewOverlay.svelte"
@ -35,7 +33,6 @@
let promise = getPackage()
let hasSynced = false
let commandPaletteModal
let loaded = false
$: selected = capitalise(
@ -75,14 +72,6 @@
$goto($builderStore.previousTopNavPath[path] || path)
}
// Event handler for the command palette
const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
commandPaletteModal.toggle()
}
}
onMount(async () => {
if (!hasSynced && application) {
try {
@ -162,11 +151,6 @@
<PreviewOverlay />
{/if}
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette />
</Modal>
<EnterpriseBasicTrialModal />
<style>

View File

@ -1,16 +1,32 @@
<script lang="ts">
import { API } from "@/api"
import AiInput from "@/components/common/ai/AIInput.svelte"
import { datasources, tables } from "@/stores/builder"
import { auth, licensing } from "@/stores/portal"
import { ActionButton, notifications } from "@budibase/bbui"
import { goto } from "@roxi/routify"
let promptText = ""
$: isEnabled = $auth?.user?.llm && !$licensing.aiCreditsExceeded
async function submitPrompt(message: string) {
await API.generateTables(message)
notifications.success("Tables created successfully!")
try {
const { createdTables } = await API.generateTables({
prompt: message,
})
const [tableToRedirect] = createdTables.sort((a, b) =>
a.name.localeCompare(b.name)
)
notifications.success(`Tables created successfully.`)
await datasources.fetch()
await tables.fetch()
$goto(`./table/${tableToRedirect.id}`)
} catch (e: any) {
notifications.error(e.message)
}
}
const examplePrompts = [
@ -59,7 +75,7 @@
}
.ai-generation :global(.spectrum-Textfield-input),
.ai-generation :global(.spectrum-ActionButton) {
background: #1d1d1d;
background: var(--spectrum-global-color-gray-75);
border-radius: 20px;
}
</style>

View File

@ -6,7 +6,7 @@
let modal
let promptUpload = false
export function show({ promptUpload: newPromptUpload = false }) {
export function show({ promptUpload: newPromptUpload = false } = {}) {
promptUpload = newPromptUpload
modal.show()
}

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { API } from "@/api"
import {
tables,
@ -16,20 +16,16 @@
import CreationPage from "@/components/common/CreationPage.svelte"
import ICONS from "@/components/backend/DatasourceNavigator/icons/index.js"
import AiTableGeneration from "./_components/AITableGeneration.svelte"
import { featureFlag } from "@/helpers"
import { FeatureFlag } from "@budibase/types"
import { featureFlags } from "@/stores/portal"
let internalTableModal
let externalDatasourceModal
let internalTableModal: CreateInternalTableModal
let externalDatasourceModal: CreateExternalDatasourceModal
let sampleDataLoading = false
let externalDatasourceLoading = false
$: disabled = sampleDataLoading || externalDatasourceLoading
$: aiTableGenerationEnabled = featureFlag.isEnabled(
FeatureFlag.AI_TABLE_GENERATION
)
$: aiTableGenerationEnabled = $featureFlags.AI_TABLE_GENERATION
const createSampleData = async () => {
sampleDataLoading = true
@ -72,7 +68,7 @@
</div>
{/if}
<DatasourceOption
on:click={internalTableModal.show}
on:click={() => internalTableModal.show()}
title="Create new table"
description="Non-relational"
{disabled}

View File

@ -1,65 +1,54 @@
<script lang="ts">
import { Body, Label, Icon } from "@budibase/bbui"
import BudibaseLogo from "./logos/Budibase.svelte"
import { Body, ActionButton } from "@budibase/bbui"
import OpenAILogo from "./logos/OpenAI.svelte"
import AnthropicLogo from "./logos/Anthropic.svelte"
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,
[Providers.Anthropic.name]: AnthropicLogo,
[Providers.TogetherAI.name]: TogetherAILogo,
[Providers.AzureOpenAI.name]: AzureOpenAILogo,
import BudibaseAILogo from "./logos/BBAI.svelte"
import type { AIProvider, ProviderConfig } from "@budibase/types"
import { type ComponentType } from "svelte"
const logos: Partial<Record<AIProvider, ComponentType>> = {
BudibaseAI: BudibaseAILogo,
OpenAI: OpenAILogo,
AzureOpenAI: AzureOpenAILogo,
}
export let config: ProviderConfig
export let disabled: boolean | null = null
export let editHandler: (() => void) | null
export let deleteHandler: (() => void) | null
export let disableHandler: (() => void) | null
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div on:click class:disabled class="option">
<div class="icon">
<svelte:component
this={logos[config.name || config.provider]}
height="18"
width="18"
/>
<div class="option">
<div class="details">
<div class="icon">
<svelte:component this={logos[config.provider]} height="26" width="26" />
</div>
<div class="header">
<Body size="S" weight={"600"}>{config.name}</Body>
</div>
<div class="controls">
{#if config.active}
<div class="tag active">Enabled</div>
{:else}
<div class="tag disabled">Disabled</div>
{/if}
</div>
</div>
<div class="header">
<Body>{config.provider}</Body>
<Label>{config.name}</Label>
</div>
<div class="controls">
{#if config.name !== "Budibase AI"}
<Icon
on:click={editHandler}
color="var(--grey-6)"
size="S"
hoverable
name="Edit"
/>
<Icon
on:click={deleteHandler}
color="var(--grey-6)"
size="S"
hoverable
name="Delete"
/>
{/if}
{#if config.active}
<div class="tag active">Activated</div>
{:else if !config.active}
<div class="tag disabled">Disabled</div>
{/if}
{#if config.isDefault}
<div class="tag default">Default</div>
<div>
{#if config.provider === "BudibaseAI"}
{#if config.active}
<ActionButton on:click={() => disableHandler && disableHandler()}>
Disable
</ActionButton>
{:else}
<ActionButton on:click={() => editHandler && editHandler()}>
Enable
</ActionButton>
{/if}
{:else}
<!-- OpenAI or AzureOpenAI -->
<ActionButton on:click={() => editHandler && editHandler()}>
{#if config.apiKey}Edit{:else}Set up{/if}
</ActionButton>
{/if}
</div>
</div>
@ -71,10 +60,16 @@
padding: 16px;
border-radius: 4px;
cursor: pointer;
display: grid;
grid-template-columns: 6% 1fr auto;
grid-gap: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.details {
display: flex;
align-items: center;
gap: 12px;
}
.option :global(label) {
@ -87,12 +82,13 @@
.header {
align-items: center;
color: var(--spectrum-global-color-gray-900);
}
.icon {
background-color: white;
height: 38px;
width: 38px;
background-color: var(--spectrum-global-color-gray-200);
height: 40px;
width: 40px;
display: flex;
justify-content: center;
align-items: center;
@ -103,33 +99,21 @@
pointer-events: none;
}
.controls {
display: grid;
grid-auto-flow: column;
grid-gap: 10px;
align-items: center;
}
.tag {
display: flex;
color: #ffffff;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
font-size: 12px;
border-radius: 5px;
}
.default {
background: var(--grey-6);
color: #fff;
}
.active {
background: var(--spectrum-global-color-green-600);
background: #004c2e;
}
.disabled {
background: var(--spectrum-global-color-red-600);
background: var(--grey-3);
}
</style>

View File

@ -56,66 +56,18 @@ describe("AISettings", () => {
expect(instance).toBeDefined()
})
describe("Licensing", () => {
it("should show the premium label on self host for custom configs", async () => {
setupEnv(Hosting.Self)
setupDOM()
const premiumTag = instance.queryByText("Premium")
expect(premiumTag).toBeInTheDocument()
})
it("should show the enterprise label on cloud for custom configs", async () => {
setupEnv(Hosting.Cloud)
setupDOM()
const enterpriseTag = instance.queryByText("Enterprise")
expect(enterpriseTag).toBeInTheDocument()
})
it("the add configuration button should not do anything the user doesn't have the correct license on cloud", async () => {
let addConfigurationButton
describe("DOM Render tests", () => {
it("the enable bb ai button should not do anything if the user doesn't have the correct license on self host", async () => {
let addAiButton
let configModal
setupEnv(Hosting.Cloud)
setupEnv(Hosting.Self, { customAIConfigsEnabled: false })
setupDOM()
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
addAiButton = instance.queryByText("Enable BB AI")
expect(addAiButton).toBeInTheDocument()
await fireEvent.click(addAiButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).not.toBeInTheDocument()
})
it("the add configuration button should open the config modal if the user has the correct license on cloud", async () => {
let addConfigurationButton
let configModal
setupEnv(
Hosting.Cloud,
{ customAIConfigsEnabled: true },
{ AI_CUSTOM_CONFIGS: true }
)
setupDOM()
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).toBeInTheDocument()
})
it("the add configuration button should open the config modal if the user has the correct license on self host", async () => {
let addConfigurationButton
let configModal
setupEnv(
Hosting.Self,
{ customAIConfigsEnabled: true },
{ AI_CUSTOM_CONFIGS: true }
)
setupDOM()
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).toBeInTheDocument()
})
})
})

View File

@ -1,76 +1,42 @@
<script>
import { ModalContent, Label, Input, Select, Toggle } from "@budibase/bbui"
import { ConfigMap, Providers } from "./constants"
<script lang="ts">
import { ModalContent, Label, Input, Select } from "@budibase/bbui"
import { ConfigMap, Models } from "./constants"
import type { ProviderConfig } from "@budibase/types"
export let config = {
active: false,
isDefault: false,
}
export let config: ProviderConfig
export let updateHandler: (_config: ProviderConfig) => void
export let enableHandler: (_config: ProviderConfig) => void
export let disableHandler: (_config: ProviderConfig) => void
export let saveHandler
export let deleteHandler
let complete: boolean
let validation
$: isEnabled = config.active && config.isDefault
$: {
const { provider, defaultModel, name, apiKey } = config
validation = provider && defaultModel && name && apiKey
complete = Boolean(provider && name && defaultModel && apiKey)
}
$: canEditBaseUrl =
config.provider && ConfigMap[config.provider].baseUrl === ""
function prefillConfig(evt) {
const provider = evt.detail
// grab the preset config from the constants for that provider and fill it in
if (ConfigMap[provider]) {
config = {
...config,
...ConfigMap[provider],
provider,
}
} else {
config.provider = provider
}
}
config.provider &&
ConfigMap[config.provider as keyof typeof ConfigMap].baseUrl === ""
</script>
<ModalContent
confirmText={"Save"}
cancelText={"Delete"}
onConfirm={saveHandler}
onCancel={deleteHandler}
disabled={!validation}
cancelText={isEnabled ? "Disable" : "Update"}
confirmText={isEnabled ? "Update" : "Enable"}
onConfirm={isEnabled
? () => updateHandler(config)
: () => enableHandler(config)}
onCancel={isEnabled
? () => disableHandler(config)
: () => updateHandler(config)}
disabled={!complete}
size="M"
title="Custom AI Configuration"
title={`Set up ${config.name}`}
>
<div class="form-row">
<Label size="M">Provider</Label>
<Select
placeholder={null}
bind:value={config.provider}
options={Object.keys(Providers)}
on:change={prefillConfig}
/>
</div>
<div class="form-row">
<Label size="M">Name</Label>
<Input
error={config.name === "Budibase AI" ? "Cannot use this name" : null}
placeholder={"Enter a name"}
bind:value={config.name}
/>
</div>
<div class="form-row">
<Label size="M">Default Model</Label>
{#if config.provider !== Providers.Custom.name}
<Select
placeholder={config.provider ? "Choose an option" : "Select a provider"}
bind:value={config.defaultModel}
options={config.provider ? Providers[config.provider].models : []}
/>
{:else}
<Input bind:value={config.defaultModel} />
{/if}
<Label size="M">API Key</Label>
<Input type="password" bind:value={config.apiKey} />
</div>
<div class="form-row">
<Label size="M">Base URL</Label>
@ -80,13 +46,14 @@
bind:value={config.baseUrl}
/>
</div>
<div class="form-row">
<Label size="M">API Key</Label>
<Input type="password" bind:value={config.apiKey} />
</div>
<div class="form-row">
<Toggle text="Active" bind:value={config.active} />
<Toggle text="Set as default" bind:value={config.isDefault} />
<Label size="M">Default Model</Label>
<Select
placeholder={config.provider ? "Choose an option" : "Select a provider"}
bind:value={config.defaultModel}
options={Models}
/>
</div>
</ModalContent>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import { ModalContent, Link } from "@budibase/bbui"
import { admin } from "@/stores/portal"
export let confirmHandler: () => void
export let cancelHandler: () => void
</script>
<ModalContent
title="Set up Budibase AI"
confirmText="Account portal"
cancelText="Cancel"
onConfirm={confirmHandler}
onCancel={cancelHandler}
>
<div>To set up Budibase AI you need a Budibase license key.</div>
<div>
Get your key from the
<Link href={$admin.accountPortalUrl}>Budibase Account Portal</Link> then add
it to your self-hosted instance via the Account → Upgrade page.
</div>
<div>
<Link href="https://docs.budibase.com/docs/quickstart-budibase-ai">
Learn more about this step.
</Link>
</div>
</ModalContent>

View File

@ -1,60 +0,0 @@
export const Providers = {
OpenAI: {
name: "OpenAI",
models: [
{ label: "GPT 4o Mini", value: "gpt-4o-mini" },
{ label: "GPT 4o", value: "gpt-4o" },
{ label: "GPT 4 Turbo", value: "gpt-4-turbo" },
{ label: "GPT 4", value: "gpt-4" },
{ label: "GPT 3.5 Turbo", value: "gpt-3.5-turbo" },
],
},
Anthropic: {
name: "Anthropic",
models: [
{ label: "Claude 3.5 Sonnet", value: "claude-3-5-sonnet-20240620" },
{ label: "Claude 3 Sonnet", value: "claude-3-sonnet-20240229" },
{ label: "Claude 3 Opus", value: "claude-3-opus-20240229" },
{ label: "Claude 3 Haiku", value: "claude-3-haiku-20240307" },
],
},
TogetherAI: {
name: "Together AI",
models: [{ label: "Llama 3 8B", value: "meta-llama/Meta-Llama-3-8B" }],
},
AzureOpenAI: {
name: "Azure OpenAI",
models: [
{ label: "GPT 4o Mini", value: "gpt-4o-mini" },
{ label: "GPT 4o", value: "gpt-4o" },
{ label: "GPT 4 Turbo", value: "gpt-4-turbo" },
{ label: "GPT 4", value: "gpt-4" },
{ label: "GPT 3.5 Turbo", value: "gpt-3.5-turbo" },
],
},
Custom: {
name: "Custom",
},
}
export const ConfigMap = {
OpenAI: {
name: "OpenAI",
baseUrl: "https://api.openai.com",
},
Anthropic: {
name: "Anthropic",
baseUrl: "https://api.anthropic.com/v1",
},
TogetherAI: {
name: "TogetherAI",
baseUrl: "https://api.together.xyz/v1",
},
AzureOpenAI: {
name: "Azure OpenAI",
baseUrl: "",
},
Custom: {
baseUrl: "",
},
}

View File

@ -0,0 +1,67 @@
import { AIProvider, ProviderConfig } from "@budibase/types"
export const Models = [
{ label: "GPT 4o Mini", value: "gpt-4o-mini" },
{ label: "GPT 4o", value: "gpt-4o" },
{ label: "GPT 4 Turbo", value: "gpt-4-turbo" },
{ label: "GPT 4", value: "gpt-4" },
{ label: "GPT 3.5 Turbo", value: "gpt-3.5-turbo" },
]
interface AIProviderDetails {
defaultConfig: ProviderConfig
models: { label: string; value: string }[]
}
export const ProviderDetails: Partial<Record<AIProvider, AIProviderDetails>> = {
BudibaseAI: {
defaultConfig: {
name: "Budibase AI",
provider: "BudibaseAI",
active: false,
isDefault: false,
},
models: [],
},
OpenAI: {
defaultConfig: {
name: "OpenAI",
provider: "OpenAI",
active: false,
isDefault: false,
baseUrl: "https://api.openai.com",
},
models: Models,
},
AzureOpenAI: {
defaultConfig: {
name: "Azure OpenAI",
provider: "AzureOpenAI",
active: false,
isDefault: false,
},
models: Models,
},
}
export const ConfigMap = {
OpenAI: {
name: "OpenAI",
baseUrl: "https://api.openai.com",
},
Anthropic: {
name: "Anthropic",
baseUrl: "https://api.anthropic.com/v1",
},
TogetherAI: {
name: "TogetherAI",
baseUrl: "https://api.together.xyz/v1",
},
AzureOpenAI: {
name: "Azure OpenAI",
baseUrl: "",
},
Custom: {
baseUrl: "",
},
}

View File

@ -5,193 +5,306 @@
Layout,
Heading,
Body,
Helpers,
Divider,
notifications,
Modal,
Tags,
Tag,
Icon,
} from "@budibase/bbui"
import { admin, licensing } from "@/stores/portal"
import BBAI from "assets/bb-ai.svg"
import { admin } from "@/stores/portal"
import { auth } from "@/stores/portal"
import { BudiStore, PersistenceType } from "@/stores/BudiStore"
import { API } from "@/api"
import AIConfigModal from "./ConfigModal.svelte"
import AIConfigTile from "./AIConfigTile.svelte"
import ConfigModal from "./ConfigModal.svelte"
import PortalModal from "./PortalModal.svelte"
import {
type AIConfig,
type AIProvider,
ConfigType,
type AIConfig,
type ProviderConfig,
} from "@budibase/types"
import { ProviderDetails } from "./constants"
let modal: Modal
let fullAIConfig: AIConfig
let editingAIConfig: ProviderConfig | undefined
let editingUuid: string | undefined
const bannerKey = `bb-ai-configuration-banner`
const bannerStore = new BudiStore<boolean>(false, {
persistence: {
type: PersistenceType.LOCAL,
key: bannerKey,
},
})
let aiConfig: AIConfig
let configModal: { show: () => void; hide: () => void }
let portalModal: { show: () => void; hide: () => void }
let modalProvider: AIProvider
let modalConfig: ProviderConfig
let providerNames: AIProvider[]
let hasLicenseKey: string | undefined
$: isCloud = $admin.cloud
$: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
$: providerNames = isCloud
? ["BudibaseAI"]
: ["BudibaseAI", "OpenAI", "AzureOpenAI"]
$: providers = aiConfig
? providerNames.map((provider: AIProvider) => ({
provider,
config: getProviderConfig(provider).config,
}))
: []
async function fetchAIConfig() {
try {
fullAIConfig = (await API.getConfig(ConfigType.AI)) as AIConfig
} catch (error) {
notifications.error("Error fetching AI config")
}
}
$: activeProvider = providers.find(p => p.config.active)?.provider
$: disabledProviders = providers.filter(p => p.provider !== activeProvider)
async function saveConfig() {
// Use existing key or generate new one
const id = editingUuid || Helpers.uuid()
// Creating first custom AI Config
if (!fullAIConfig && editingAIConfig) {
fullAIConfig = {
type: ConfigType.AI,
config: {
[id]: editingAIConfig,
},
function getExistingProviderConfig(provider: AIProvider) {
for (const [key, config] of Object.entries(aiConfig.config)) {
if (config.provider === provider) {
return { key, config }
}
} else {
// We don't store the default BB AI config in the DB
delete fullAIConfig.config.budibase_ai
}
return { key: undefined, config: undefined }
}
// unset the default value from other configs if default is set
if (editingAIConfig?.isDefault) {
for (let key in fullAIConfig.config) {
if (key !== id) {
fullAIConfig.config[key].isDefault = false
}
}
}
// Add new or update existing custom AI Config
if (editingAIConfig) {
fullAIConfig.config[id] = editingAIConfig
}
fullAIConfig.type = ConfigType.AI
function getProviderConfig(provider: AIProvider) {
const { key, config } = getExistingProviderConfig(provider)
if (config) {
return { key, config }
}
const details = ProviderDetails[provider] // Update to use the provider parameter
if (!details) {
throw new Error(`Provider ${key} not found`)
}
return { key: provider, config: details.defaultConfig }
}
async function saveConfig(config: AIConfig) {
try {
await API.saveConfig(fullAIConfig)
notifications.success(`Successfully saved and activated AI Configuration`)
} catch (error) {
notifications.error(
`Failed to save AI Configuration, reason: ${
error instanceof Error ? error.message : "Unknown"
}`
)
} finally {
await fetchAIConfig()
await API.saveConfig(config)
aiConfig = (await API.getConfig(ConfigType.AI)) as AIConfig
await auth.getSelf()
notifications.success(`AI provider updated`)
} catch (err: any) {
notifications.error(err.message || "Failed to update AI provider")
}
}
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
delete fullAIConfig.config[key]
async function enableProvider(
provider: AIProvider,
updates?: Partial<Omit<ProviderConfig, "active" | "isDefault">>
) {
// Ensure that only one provider is active at a time.
for (const config of Object.values(aiConfig.config)) {
config.active = false
config.isDefault = false
}
const { key, config } = getProviderConfig(provider)
aiConfig.config[key] = {
...config,
...updates,
active: true,
isDefault: true,
}
await saveConfig(aiConfig)
configModal?.hide()
}
async function disableProvider(provider: AIProvider) {
const { key, config } = getProviderConfig(provider)
aiConfig.config[key] = { ...config, active: false, isDefault: false }
await saveConfig(aiConfig)
}
async function updateProvider(
provider: AIProvider,
updates: Partial<Omit<ProviderConfig, "active" | "isDefault">>
) {
const { key, config } = getProviderConfig(provider)
aiConfig.config[key] = { ...config, ...updates }
await saveConfig(aiConfig)
}
async function handleEnable(provider: AIProvider) {
modalProvider = provider
if (provider === "BudibaseAI" && !isCloud && !hasLicenseKey) {
portalModal.show()
return
}
if (provider === "BudibaseAI") {
await enableProvider(provider)
return
}
const { config } = getProviderConfig(provider)
modalConfig = config
configModal.show()
}
function setBannerLocalStorageKey() {
localStorage.setItem(bannerKey, "true")
}
onMount(async () => {
try {
await API.saveConfig(fullAIConfig)
notifications.success(`Deleted config`)
} catch (error) {
notifications.error(
`Failed to delete config, reason: ${
error instanceof Error ? error.message : "Unknown"
}`
)
} finally {
await fetchAIConfig()
aiConfig = (await API.getConfig(ConfigType.AI)) as AIConfig
const licenseKeyResponse = await API.getLicenseKey()
hasLicenseKey = licenseKeyResponse?.licenseKey
} catch {
notifications.error("Error fetching AI settings")
}
}
function editConfig(uuid: string) {
editingUuid = uuid
editingAIConfig = fullAIConfig?.config[editingUuid]
modal.show()
}
function newConfig() {
editingUuid = undefined
editingAIConfig = undefined
modal.show()
}
onMount(() => {
fetchAIConfig()
})
</script>
<Modal bind:this={modal}>
<AIConfigModal
saveHandler={saveConfig}
deleteHandler={deleteConfig}
bind:config={editingAIConfig}
/>
</Modal>
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="header">
<Heading size="M">AI</Heading>
{#if !isCloud && !customAIConfigsEnabled}
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
{:else if isCloud && !customAIConfigsEnabled}
<Tags>
<Tag icon="LockClosed">Enterprise</Tag>
</Tags>
{/if}
</div>
<Body
>Connect an LLM to enable AI features. You can only enable one LLM at a
time.</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}`}>
<Layout noPadding>
<div class="config-heading">
<Heading size="S">AI Configurations</Heading>
<Button
size="S"
cta={customAIConfigsEnabled}
secondary={!customAIConfigsEnabled}
on:click={customAIConfigsEnabled ? newConfig : null}
>
Add configuration
</Button>
{#if !activeProvider && !$bannerStore}
<div class="banner">
<div class="banner-content">
<div class="banner-icon">
<img src={BBAI} alt="BB AI" width="24" height="24" />
</div>
<div>Try BB AI for free. 50,000 tokens included. No CC required.</div>
</div>
<Body size="S"
>Use the following interface to select your preferred AI configuration.</Body
>
{#if customAIConfigsEnabled}
<Body size="S">Select your AI Model:</Body>
{/if}
{#if fullAIConfig?.config}
{#each Object.keys(fullAIConfig.config) as key}
<div class="banner-buttons">
<Button
primary
cta
size="S"
on:click={() => handleEnable("BudibaseAI")}
>
Enable BB AI
</Button>
<Icon
hoverable
name="Close"
on:click={() => {
setBannerLocalStorageKey()
bannerStore.set(true)
}}
/>
</div>
</div>
{/if}
<div class="section">
<div class="section-title">Enabled</div>
{#if activeProvider}
<AIConfigTile
config={getProviderConfig(activeProvider).config}
editHandler={() => handleEnable(activeProvider)}
disableHandler={() => disableProvider(activeProvider)}
/>
{:else}
<div class="no-enabled">
<Body size="S">No LLMs are enabled</Body>
</div>
{/if}
{#if disabledProviders.length > 0}
<div class="section-title disabled-title">Disabled</div>
<div class="ai-list">
{#each disabledProviders as { provider, config } (provider)}
<AIConfigTile
config={fullAIConfig.config[key]}
editHandler={customAIConfigsEnabled ? () => editConfig(key) : null}
deleteHandler={customAIConfigsEnabled
? () => deleteConfig(key)
: null}
{config}
editHandler={() => handleEnable(provider)}
disableHandler={() => disableProvider(provider)}
/>
{/each}
{/if}
</Layout>
</div>
{/if}
</div>
</Layout>
<style>
.config-heading {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: -18px;
}
<Modal bind:this={portalModal}>
<PortalModal
confirmHandler={() => {
window.open($admin.accountPortalUrl, "_blank")
portalModal.hide()
}}
cancelHandler={() => portalModal.hide()}
/>
</Modal>
<Modal bind:this={configModal}>
<ConfigModal
config={modalConfig}
updateHandler={updatedConfig =>
updateProvider(modalProvider, updatedConfig)}
enableHandler={updatedConfig =>
enableProvider(modalProvider, updatedConfig)}
disableHandler={() => disableProvider(modalProvider)}
/>
</Modal>
<style>
.header {
display: flex;
align-items: center;
gap: 12px;
}
.banner {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #2e3851;
border-radius: var(--border-radius-m);
padding: var(--spacing-s);
}
.banner-content {
display: flex;
align-items: center;
gap: 12px;
}
.banner-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius-s);
width: 32px;
height: 32px;
}
.banner-buttons {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.ai-list {
margin-top: var(--spacing-l);
margin-bottom: var(--spacing-l);
display: flex;
flex-direction: column;
gap: 12px;
}
.no-enabled {
padding: 16px;
background-color: var(--spectrum-global-color-gray-75);
border: 1px solid var(--grey-4);
border-radius: var(--border-radius-s);
}
.section-title {
margin-bottom: var(--spacing-m);
}
.disabled-title {
margin-top: var(--spacing-xl);
}
</style>

View File

@ -0,0 +1,75 @@
<script>
export let width
export let height
</script>
<svg
{width}
{height}
viewBox="0 0 636 636"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.3447 440.727L195.1 621.483C203.025 629.408 215.866 629.408 223.79 621.483L302.5 542.774C309.583 535.69 312.931 525.68 311.54 515.75L290.403 366.368C288.85 355.5 280.311 346.962 269.46 345.425L120.078 324.288C110.164 322.881 100.154 326.229 93.0544 333.328L14.3447 412.038C6.42032 419.962 6.42032 432.803 14.3447 440.727Z"
fill="url(#paint0_linear_608_138)"
/>
<path
d="M440.54 621.487L621.296 440.732C629.221 432.807 629.221 419.967 621.296 412.042L542.587 333.332C535.503 326.249 525.493 322.901 515.563 324.292L366.181 345.429C355.313 346.982 346.775 355.521 345.238 366.372L324.101 515.754C322.694 525.668 326.042 535.678 333.141 542.778L411.851 621.487C419.775 629.412 432.616 629.412 440.54 621.487Z"
fill="url(#paint1_linear_608_138)"
/>
<path
d="M621.655 195.108L440.9 14.3526C432.975 6.4283 420.135 6.42825 412.21 14.3526L333.5 93.0623C326.417 100.146 323.069 110.156 324.46 120.086L345.597 269.468C347.15 280.335 355.689 288.874 366.54 290.411L515.922 311.548C525.836 312.955 535.846 309.607 542.946 302.508L621.655 223.798C629.58 215.873 629.58 203.033 621.655 195.108Z"
fill="url(#paint2_linear_608_138)"
/>
<path
d="M195.46 14.3447L14.7037 195.1C6.77937 203.025 6.77932 215.865 14.7037 223.79L93.4134 302.5C100.497 309.583 110.507 312.931 120.437 311.54L269.819 290.403C280.687 288.85 289.225 280.311 290.762 269.46L311.899 120.078C313.306 110.164 309.958 100.154 302.859 93.0544L224.149 14.3447C216.225 6.42033 203.384 6.42032 195.46 14.3447Z"
fill="url(#paint3_linear_608_138)"
/>
<defs>
<linearGradient
id="paint0_linear_608_138"
x1="196.613"
y1="602.376"
x2="295.324"
y2="374.302"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6E56FF" />
<stop offset="1" stop-color="#9F8FFF" />
</linearGradient>
<linearGradient
id="paint1_linear_608_138"
x1="602.189"
y1="439.219"
x2="374.115"
y2="340.508"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6E56FF" />
<stop offset="1" stop-color="#9F8FFF" />
</linearGradient>
<linearGradient
id="paint2_linear_608_138"
x1="439.387"
y1="33.4599"
x2="340.676"
y2="261.534"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6E56FF" />
<stop offset="1" stop-color="#9F8FFF" />
</linearGradient>
<linearGradient
id="paint3_linear_608_138"
x1="33.811"
y1="196.613"
x2="261.885"
y2="295.324"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6E56FF" />
<stop offset="1" stop-color="#9F8FFF" />
</linearGradient>
</defs>
</svg>

View File

@ -17,16 +17,9 @@ import { bindings } from "@/helpers"
import { getBindableProperties } from "@/dataBinding"
import { componentStore } from "./components"
import { getSettingsDefinition } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
function reduceBy<TItem extends {}, TKey extends keyof TItem>(
key: TKey,
list: TItem[]
): Record<string, TItem> {
return list.reduce<Record<string, TItem>>((result, item) => {
result[item[key] as string] = item
return result
}, {})
}
const reduceBy = utils.toMap
const friendlyNameByType: Partial<Record<UIDatasourceType, string>> = {
viewV2: "view",

View File

@ -1,8 +1,38 @@
import { derived, Readable } from "svelte/store"
import { auth } from "@/stores/portal"
import { FeatureFlags, FeatureFlagDefaults } from "@budibase/types"
import { FeatureFlag, FeatureFlags, FeatureFlagDefaults } from "@budibase/types"
import { BudiStore } from "@/stores/BudiStore"
import { API } from "@/api"
import { notifications } from "@budibase/bbui"
export const featureFlags: Readable<FeatureFlags> = derived(auth, $auth => ({
...FeatureFlagDefaults,
...($auth?.user?.flags || {}),
}))
class FeatureFlagStore extends BudiStore<FeatureFlags> {
constructor() {
super(FeatureFlagDefaults)
auth.subscribe($auth => {
this.set({
...FeatureFlagDefaults,
...($auth?.user?.flags || {}),
})
})
}
async setFlag(flag: FeatureFlag, value: boolean) {
try {
await API.overrideFeatureFlags({
[flag]: value,
})
// The feature flag store is derived from the auth store, so we need to
// refresh the auth store to update the feature flags.
await auth.getSelf()
notifications.success(
`Feature flag ${flag} ${value ? "enabled" : "disabled"}`
)
} catch (e) {
console.error(e)
notifications.error(
`Failed to set feature flag ${flag} to ${value}, see console for details`
)
}
}
}
export const featureFlags = new FeatureFlagStore()

View File

@ -200,9 +200,6 @@ class LicensingStore extends BudiStore<LicensingState> {
Constants.Features.APP_BUILDERS
)
const budibaseAIEnabled = features.includes(Constants.Features.BUDIBASE_AI)
const customAIConfigsEnabled = features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
const customAppScriptsEnabled = features.includes(
Constants.Features.CUSTOM_APP_SCRIPTS
)
@ -220,7 +217,6 @@ class LicensingStore extends BudiStore<LicensingState> {
brandingEnabled,
pwaEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled,
environmentVariablesEnabled,
auditLogsEnabled,

View File

@ -1,11 +1,17 @@
import { GenerateJsRequest, GenerateJsResponse } from "@budibase/types"
import {
GenerateJsRequest,
GenerateJsResponse,
GenerateTablesRequest,
GenerateTablesResponse,
} from "@budibase/types"
import { BaseAPIClient } from "./types"
import { sleep } from "../utils/utils"
export interface AIEndpoints {
generateCronExpression: (prompt: string) => Promise<{ message: string }>
generateJs: (req: GenerateJsRequest) => Promise<GenerateJsResponse>
generateTables: (prompt: string) => Promise<void>
generateTables: (
req: GenerateTablesRequest
) => Promise<GenerateTablesResponse>
}
export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
@ -25,8 +31,11 @@ export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
body: req,
})
},
generateTables: async prompt => {
console.warn({ prompt })
await sleep(1000)
generateTables: async req => {
return await API.post({
url: "/api/ai/tables",
body: req,
})
},
})

View File

@ -0,0 +1,19 @@
import { OverrideFeatureFlagRequest } from "@budibase/types"
import { BaseAPIClient } from "./types"
export interface FeatureFlagEndpoints {
overrideFeatureFlags: (flags: Record<string, boolean>) => Promise<void>
}
export const buildFeatureFlagEndpoints = (
API: BaseAPIClient
): FeatureFlagEndpoints => ({
overrideFeatureFlags: async flags => {
const body: OverrideFeatureFlagRequest = { flags }
return await API.patch({
url: "/api/features",
body,
parseResponse: () => {},
})
},
})

View File

@ -46,6 +46,7 @@ import { buildLogsEndpoints } from "./logs"
import { buildMigrationEndpoints } from "./migrations"
import { buildRowActionEndpoints } from "./rowActions"
import { buildOAuth2Endpoints } from "./oauth2"
import { buildFeatureFlagEndpoints } from "./features"
export type { APIClient } from "./types"
@ -289,6 +290,7 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
...buildAuditLogEndpoints(API),
...buildLogsEndpoints(API),
...buildMigrationEndpoints(API),
...buildFeatureFlagEndpoints(API),
viewV2: buildViewV2Endpoints(API),
rowActions: buildRowActionEndpoints(API),
oauth2: buildOAuth2Endpoints(API),

View File

@ -10,6 +10,7 @@ import { ConfigEndpoints } from "./configs"
import { DatasourceEndpoints } from "./datasources"
import { EnvironmentVariableEndpoints } from "./environmentVariables"
import { EventEndpoints } from "./events"
import { FeatureFlagEndpoints } from "./features"
import { FlagEndpoints } from "./flags"
import { GroupEndpoints } from "./groups"
import { LayoutEndpoints } from "./layouts"
@ -133,6 +134,7 @@ export type APIClient = BaseAPIClient &
TableEndpoints &
TemplateEndpoints &
UserEndpoints &
FeatureFlagEndpoints &
ViewEndpoints & {
rowActions: RowActionEndpoints
viewV2: ViewV2Endpoints

View File

@ -86,6 +86,7 @@ export const PlanType = {
PRO: "pro",
BUSINESS: "business",
PREMIUM: "premium",
PREMIUM_PLUS: "premium_plus",
ENTERPRISE: "enterprise",
ENTERPRISE_BASIC_TRIAL: "enterprise_basic_trial",
}

@ -1 +1 @@
Subproject commit 6c9ccbb8a5737733448f6b0e23696de1ed343015
Subproject commit ce1cea2a6bced7639efb8e990e2dd5654692f2ed

View File

@ -46,7 +46,6 @@ async function init() {
HTTP_LOGGING: "0",
VERSION: "0.0.0+local",
PASSWORD_MIN_LENGTH: "1",
OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd",
BUDICLOUD_URL: "https://budibaseqa.app",
}

View File

@ -0,0 +1,27 @@
import {
GenerateTablesRequest,
GenerateTablesResponse,
UserCtx,
} from "@budibase/types"
import { ai } from "@budibase/pro"
import sdk from "../../sdk"
export async function generateTables(
ctx: UserCtx<GenerateTablesRequest, GenerateTablesResponse>
) {
const { prompt } = ctx.request.body
const tableGenerator = await ai.TableGeneration.init({
generateTablesDelegate: sdk.ai.helpers.generateTables,
generateDataDelegate: sdk.ai.helpers.generateRows,
})
const createdTables = await tableGenerator.generate(
prompt,
ctx.user._id || ""
)
ctx.body = {
createdTables,
}
}

View File

@ -26,7 +26,6 @@ import {
db as dbCore,
docIds,
env as envCore,
ErrorCode,
events,
objectStore,
roles,
@ -70,6 +69,7 @@ import {
AddAppSampleDataResponse,
UnpublishAppResponse,
SetRevertableAppVersionResponse,
ErrorCode,
} from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk"

View File

@ -0,0 +1,26 @@
import {
UserCtx,
OverrideFeatureFlagRequest,
FeatureFlagCookie,
} from "@budibase/types"
import { Cookie, utils } from "@budibase/backend-core"
export async function override(ctx: UserCtx<OverrideFeatureFlagRequest, void>) {
const { flags } = ctx.request.body
let cookie = utils.getCookie<FeatureFlagCookie>(ctx, Cookie.FeatureFlags)
if (!cookie) {
cookie = {
flags: {},
}
}
cookie.flags = {
...cookie.flags,
...flags,
}
utils.setCookie(ctx, cookie, Cookie.FeatureFlags)
ctx.status = 200
}

View File

@ -91,6 +91,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const result = await finaliseRow(source, row, {
updateFormula: true,
updateAIColumns: true,
})
return { ...result, oldRow }

View File

@ -10,7 +10,6 @@ import * as linkRows from "../../../db/linkedRows"
import isEqual from "lodash/isEqual"
import { cloneDeep, merge } from "lodash/fp"
import sdk from "../../../sdk"
import * as pro from "@budibase/pro"
function mergeRows(row1: Row, row2: Row) {
const merged = merge(row1, row2)
@ -82,6 +81,7 @@ export async function updateRelatedFormula(
relatedRows[tableId].map(related =>
finaliseRow(relatedTable, related, {
updateFormula: false,
updateAIColumns: false,
})
)
)
@ -137,10 +137,10 @@ export async function updateAllFormulasInTable(table: Table) {
export async function finaliseRow(
source: Table | ViewV2,
row: Row,
opts?: { updateFormula: boolean }
opts?: { updateFormula: boolean; updateAIColumns: boolean }
) {
const db = context.getAppDB()
const { updateFormula = true } = opts || {}
const { updateFormula = true, updateAIColumns = true } = opts || {}
const table = sdk.views.isView(source)
? await sdk.views.getTable(source.id)
: source
@ -156,10 +156,7 @@ export async function finaliseRow(
contextRows: [enrichedRow],
})
const aiEnabled =
(await pro.features.isBudibaseAIEnabled()) ||
(await pro.features.isAICustomConfigsEnabled())
if (aiEnabled) {
if (updateAIColumns) {
row = await processAIColumns(table, row, {
contextRows: [enrichedRow],
})

View File

@ -281,7 +281,7 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
const sideNav = appInfo.navigation.navigation === "Left"
const hideFooter =
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
const themeVariables = getThemeVariables(appInfo?.theme || {})
const themeVariables = getThemeVariables(appInfo?.theme)
const hasPWA = Object.keys(appInfo.pwa || {}).length > 0
const manifestUrl = hasPWA ? `/api/apps/${appId}/manifest.json` : ""
const addAppScripts =
@ -376,30 +376,14 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
// just return the app info for jest to assert on
ctx.body = appInfo
}
} catch (error) {
if (!env.isJest()) {
const props: BudibaseAppProps = {
usedPlugins: [],
title: branding?.metaTitle || "",
metaTitle: branding?.metaTitle || "",
metaImage:
branding?.metaImageUrl ||
"https://res.cloudinary.com/daog6scxm/image/upload/v1698759482/meta-images/plain-branded-meta-image-coral_ocxmgu.png",
metaDescription: branding?.metaDescription || "",
favicon:
branding.faviconUrl !== ""
? await objectStore.getGlobalFileUrl("settings", "faviconUrl")
: "",
}
const { head, html, css } = AppComponent.render({ props })
const appHbs = loadHandlebarsFile(appHbsPath)
ctx.body = await processString(appHbs, {
head,
body: html,
style: css.code,
})
} catch (error: any) {
let msg = "An unknown error occurred"
if (typeof error === "string") {
msg = error
} else if (error?.message) {
msg = error.message
}
ctx.throw(500, msg)
}
}

View File

@ -31,7 +31,7 @@ router.get("/health", async ctx => {
})
router.get("/version", ctx => (ctx.body = envCore.VERSION))
router.use(middleware.errorHandling)
router.use(middleware.errorHandling).use(middleware.featureFlagCookie)
// only add the routes if they are enabled
if (apiEnabled()) {

View File

@ -0,0 +1,9 @@
import * as ai from "../controllers/ai"
import Router from "@koa/router"
import { auth } from "@budibase/backend-core"
const router: Router = new Router()
router.post("/api/ai/tables", auth.builderOrAdmin, ai.generateTables)
export default router

View File

@ -0,0 +1,14 @@
import Router from "@koa/router"
import * as controller from "../controllers/features"
import { validateBody } from "../../middleware/zod-validator"
import { z } from "zod"
const router: Router = new Router()
const validator = z.object({
flags: z.record(z.boolean()),
})
router.patch("/api/features", validateBody(validator), controller.override)
export default router

View File

@ -30,6 +30,8 @@ import Router from "@koa/router"
import { api as pro } from "@budibase/pro"
import rowActionRoutes from "./rowAction"
import oauth2Routes from "./oauth2"
import featuresRoutes from "./features"
import aiRoutes from "./ai"
export { default as staticRoutes } from "./static"
export { default as publicRoutes } from "./public"
@ -71,7 +73,9 @@ export const mainRoutes: Router[] = [
rowActionRoutes,
proAiRoutes,
oauth2Routes,
featuresRoutes,
// these need to be handled last as they still use /api/:tableId
// this could be breaking as koa may recognise other routes as this
tableRoutes,
aiRoutes,
]

View File

@ -1,21 +1,25 @@
import { z } from "zod"
import { zodResponseFormat } from "openai/helpers/zod"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import nock from "nock"
import { configs, env, features, setEnv } from "@budibase/backend-core"
import {
AIInnerConfig,
AIOperationEnum,
AttachmentSubType,
ConfigType,
Feature,
FieldType,
License,
PlanModel,
PlanType,
ProviderConfig,
StructuredOutput,
RelationshipType,
} from "@budibase/types"
import { context } from "@budibase/backend-core"
import { generator, mocks } from "@budibase/backend-core/tests"
import { ai, quotas } from "@budibase/pro"
import { generator } from "@budibase/backend-core/tests"
import { quotas, ai } from "@budibase/pro"
import { MockLLMResponseFn } from "../../../tests/utilities/mocks/ai"
import { mockAnthropicResponse } from "../../../tests/utilities/mocks/ai/anthropic"
@ -36,22 +40,27 @@ interface TestSetup {
}
function budibaseAI(): SetupFn {
return async () => {
const cleanup = setEnv({
OPENAI_API_KEY: "test-key",
return async (config: TestConfiguration) => {
await config.doInTenant(async () => {
await configs.save({
type: ConfigType.AI,
config: {
budibaseAI: {
provider: "BudibaseAI",
name: "Budibase AI",
active: true,
isDefault: true,
},
},
})
})
mocks.licenses.useBudibaseAI()
return async () => {
mocks.licenses.useCloudFree()
cleanup()
}
return setEnv({ OPENAI_API_KEY: "test-key", SELF_HOSTED: false })
}
}
function customAIConfig(providerConfig: Partial<ProviderConfig>): SetupFn {
return async (config: TestConfiguration) => {
mocks.licenses.useAICustomConfigs()
const innerConfig: AIInnerConfig = {
myaiconfig: {
provider: "OpenAI",
@ -73,8 +82,6 @@ function customAIConfig(providerConfig: Partial<ProviderConfig>): SetupFn {
)
return async () => {
mocks.licenses.useCloudFree()
await config.doInTenant(async () => {
const db = context.getGlobalDB()
await db.remove(id, rev)
@ -414,11 +421,12 @@ describe("BudibaseAI", () => {
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() }),
}
const structuredOutput = zodResponseFormat(
z.object({
[generator.word()]: z.string(),
}),
"key"
)
mockChatGPTResponse(gptResponse, { format: structuredOutput })
const { message } = await config.api.ai.chat({
messages: [{ role: "user", content: "Hello!" }],
@ -431,4 +439,513 @@ describe("BudibaseAI", () => {
expect(usage.monthly.current.budibaseAICredits).toBeGreaterThan(0)
})
})
describe("POST /api/ai/tables", () => {
let featureCleanup: () => void
beforeAll(() => {
featureCleanup = features.testutils.setFeatureFlags("*", {
AI_TABLE_GENERATION: true,
})
})
afterAll(() => {
featureCleanup()
})
beforeEach(async () => {
await config.newTenant()
nock.cleanAll()
})
const mockAIGenerationStructure = (
generationStructure: ai.GenerationStructure
) =>
mockChatGPTResponse(JSON.stringify(generationStructure), {
format: zodResponseFormat(ai.generationStructure, "key"),
})
const mockAIColumnGeneration = (
generationStructure: ai.GenerationStructure,
aiColumnGeneration: ai.AIColumnSchemas
) =>
mockChatGPTResponse(JSON.stringify(aiColumnGeneration), {
format: zodResponseFormat(
ai.aiColumnSchemas(
ai.aiTableResponseToTableSchema(generationStructure)
),
"key"
),
})
const mockDataGeneration = (
dataGeneration: Record<string, Record<string, any>[]>
) =>
mockChatGPTResponse(JSON.stringify(dataGeneration), {
format: zodResponseFormat(ai.tableDataStructuredOutput([]), "key"),
})
const mockProcessAIColumn = (response: string) =>
mockChatGPTResponse(response)
it("handles correct chat response", async () => {
const prompt = "Create me a table for managing IT tickets"
const generationStructure: ai.GenerationStructure = {
tables: [
{
name: "Tickets",
primaryDisplay: "Title",
schema: [
{
name: "Title",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
{
name: "Description",
type: FieldType.LONGFORM,
constraints: {
presence: true,
},
},
{
name: "Priority",
type: FieldType.OPTIONS,
constraints: {
inclusion: ["Low", "Medium", "High"],
presence: true,
},
},
{
name: "Status",
type: FieldType.OPTIONS,
constraints: {
inclusion: ["Open", "In Progress", "Closed"],
presence: true,
},
},
{
name: "Assignee",
type: FieldType.LINK,
tableId: "Employees",
relationshipType: RelationshipType.MANY_TO_ONE,
reverseFieldName: "AssignedTickets",
relationshipId: "TicketUser",
},
{
name: "Created Date",
type: FieldType.DATETIME,
ignoreTimezones: false,
dateOnly: true,
},
{
name: "Resolution Time (Days)",
type: FieldType.FORMULA,
formula:
'return (new Date() - new Date($("Created Date"))) / (1000 * 60 * 60 * 24);',
responseType: FieldType.NUMBER,
},
{
name: "Attachment",
type: FieldType.ATTACHMENT_SINGLE,
},
],
},
{
name: "Employees",
primaryDisplay: "First Name",
schema: [
{
name: "First Name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
{
name: "Last Name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
{
name: "Position",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
{
name: "Photo",
type: FieldType.ATTACHMENT_SINGLE,
subtype: AttachmentSubType.IMAGE,
},
{
name: "Documents",
type: FieldType.ATTACHMENTS,
},
{
name: "AssignedTickets",
type: FieldType.LINK,
tableId: "Tickets",
relationshipType: RelationshipType.ONE_TO_MANY,
reverseFieldName: "Assignee",
relationshipId: "TicketUser",
},
],
},
],
}
mockAIGenerationStructure(generationStructure)
const aiColumnGeneration: ai.AIColumnSchemas = {
Tickets: [
{
name: "Ticket Summary",
type: FieldType.AI,
operation: AIOperationEnum.SUMMARISE_TEXT,
columns: ["Title", "Description"],
},
{
name: "Translated Description",
type: FieldType.AI,
operation: AIOperationEnum.TRANSLATE,
column: "Description",
language: "es",
},
],
Employees: [
{
name: "Role Category",
type: FieldType.AI,
operation: AIOperationEnum.CATEGORISE_TEXT,
columns: ["Position"],
categories: "Manager,Staff,Intern,Contractor",
},
],
}
mockAIColumnGeneration(generationStructure, aiColumnGeneration)
nock("https://photourl.com").get("/any.png").reply(200).persist()
const dataGeneration: Record<string, Record<string, any>[]> = {
Tickets: [
{
Title: "System slow performance",
Description:
"User reports significant slowdowns when using multiple applications simultaneously on their PC.",
Priority: "Medium",
Status: "Closed",
"Created Date": "2025-04-17",
Attachment: {
name: "performance_logs.txt",
extension: ".txt",
content: "performance logs",
},
},
{
Title: "Email delivery failure",
Description:
"Emails sent to external clients are bouncing back. Bounce back message: '550: Recipient address rejected'.",
Priority: "Medium",
Status: "In Progress",
"Created Date": "2025-04-19",
Attachment: {
name: "email_bounce_back.txt",
extension: ".txt",
content: "Email delivery failure",
},
},
{
Title: "Software installation request",
Description:
"Request to install Adobe Photoshop on users workstation for design work.",
Priority: "Low",
Status: "In Progress",
"Created Date": "2025-04-18",
Attachment: {
name: "software_request_form.pdf",
extension: ".pdf",
content: "Software installation request",
},
},
{
Title: "Unable to connect to VPN",
Description:
"User is experiencing issues when trying to connect to the VPN. Error message: 'VPN connection failed due to incorrect credentials'.",
Priority: "High",
Status: "Open",
"Created Date": "2025-04-20",
Attachment: {
name: "vpn_error_screenshot.pdf",
extension: ".pdf",
content: "vpn error",
},
},
],
Employees: [
{
"First Name": "Joshua",
"Last Name": "Lee",
Position: "Application Developer",
Photo: "https://photourl.com/any.png",
Documents: [
{
name: "development_guidelines.pdf",
extension: ".pdf",
content: "any content",
},
{
name: "project_documents.txt",
extension: ".txt",
content: "any content",
},
],
},
{
"First Name": "Emily",
"Last Name": "Davis",
Position: "Software Deployment Technician",
Photo: "https://photourl.com/any.png",
Documents: [
{
name: "software_license_list.txt",
extension: ".txt",
content: "any content",
},
{
name: "deployment_guide.pdf",
extension: ".pdf",
content: "any content",
},
{
name: "installation_logs.txt",
extension: ".txt",
content: "any content",
},
],
},
{
"First Name": "James",
"Last Name": "Smith",
Position: "IT Support Specialist",
Photo: "https://photourl.com/any.png",
Documents: [
{
name: "certificates.pdf",
extension: ".pdf",
content: "any content",
},
{
name: "employment_contract.pdf",
extension: ".pdf",
content: "any content",
},
],
},
{
"First Name": "Jessica",
"Last Name": "Taylor",
Position: "Cybersecurity Analyst",
Photo: "https://photourl.com/any.png",
Documents: [
{
name: "security_audit_report.pdf",
extension: ".pdf",
content: "any content",
},
{
name: "incident_response_plan.pdf",
extension: ".pdf",
content: "any content",
},
],
},
{
"First Name": "Ashley",
"Last Name": "Harris",
Position: "Database Administrator",
Photo: "https://photourl.com/any.png",
Documents: [
{
name: "database_backup.txt",
extension: ".txt",
content: "any content",
},
{
name: "permission_settings.pdf",
extension: ".pdf",
content: "any content",
},
],
},
],
}
mockDataGeneration(dataGeneration)
mockProcessAIColumn("Mock LLM Response")
const { createdTables } = await config.api.ai.generateTables({ prompt })
expect(createdTables).toEqual([
{ id: expect.stringMatching(/ta_\w+/), name: "Tickets" },
{ id: expect.stringMatching(/ta_\w+/), name: "Employees" },
])
const tables = [
await config.api.table.get(createdTables[0].id),
await config.api.table.get(createdTables[1].id),
]
expect(tables).toEqual([
expect.objectContaining({
name: "Tickets",
schema: {
Title: {
name: "Title",
type: "string",
constraints: {
presence: true,
},
aiGenerated: true,
},
Description: {
name: "Description",
type: "longform",
constraints: {
presence: true,
},
aiGenerated: true,
},
Priority: {
name: "Priority",
type: "options",
constraints: {
inclusion: ["Low", "Medium", "High"],
presence: true,
},
aiGenerated: true,
},
Status: {
name: "Status",
type: "options",
constraints: {
inclusion: ["Open", "In Progress", "Closed"],
presence: true,
},
aiGenerated: true,
},
Assignee: {
name: "Assignee",
type: "link",
tableId: createdTables[1].id,
fieldName: "AssignedTickets",
relationshipType: "one-to-many",
aiGenerated: true,
},
"Created Date": {
name: "Created Date",
type: "datetime",
ignoreTimezones: false,
dateOnly: true,
aiGenerated: true,
},
"Resolution Time (Days)": {
name: "Resolution Time (Days)",
type: "formula",
formula:
'{{ js "cmV0dXJuIChuZXcgRGF0ZSgpIC0gbmV3IERhdGUoJCgiQ3JlYXRlZCBEYXRlIikpKSAvICgxMDAwICogNjAgKiA2MCAqIDI0KTs=" }}',
responseType: "number",
aiGenerated: true,
},
Attachment: {
name: "Attachment",
type: "attachment_single",
aiGenerated: true,
},
"Ticket Summary": {
name: "Ticket Summary",
type: "ai",
operation: "SUMMARISE_TEXT",
columns: ["Title", "Description"],
aiGenerated: true,
},
"Translated Description": {
name: "Translated Description",
type: "ai",
operation: "TRANSLATE",
column: "Description",
language: "es",
aiGenerated: true,
},
},
aiGenerated: true,
}),
expect.objectContaining({
name: "Employees 2",
schema: {
"First Name": {
constraints: {
presence: true,
},
name: "First Name",
type: "string",
aiGenerated: true,
},
"Last Name": {
constraints: {
presence: true,
},
name: "Last Name",
type: "string",
aiGenerated: true,
},
Photo: {
name: "Photo",
subtype: "image",
type: "attachment_single",
aiGenerated: true,
},
Position: {
constraints: {
presence: true,
},
name: "Position",
type: "string",
aiGenerated: true,
},
AssignedTickets: {
fieldName: "Assignee",
name: "AssignedTickets",
relationshipType: "many-to-one",
tableId: createdTables[0].id,
type: "link",
aiGenerated: true,
},
Documents: {
name: "Documents",
type: "attachment",
aiGenerated: true,
},
"Role Category": {
categories: "Manager,Staff,Intern,Contractor",
columns: ["Position"],
name: "Role Category",
operation: "CATEGORISE_TEXT",
type: "ai",
aiGenerated: true,
},
},
aiGenerated: true,
}),
])
const tickets = await config.api.row.fetch(createdTables[0].id)
expect(tickets).toHaveLength(4)
const employees = await config.api.row.fetch(createdTables[1].id)
expect(employees).toHaveLength(5)
})
})
})

View File

@ -4,7 +4,6 @@ import { Model, MonthlyQuotaName, QuotaUsageType } from "@budibase/types"
import TestConfiguration from "../../..//tests/utilities/TestConfiguration"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
import nock from "nock"
import { mocks } from "@budibase/backend-core/tests"
import { quotas } from "@budibase/pro"
describe("test the openai action", () => {
@ -98,16 +97,13 @@ describe("test the openai action", () => {
})
it("should ensure that the pro AI module is called when the budibase AI features are enabled", async () => {
mocks.licenses.useBudibaseAI()
mocks.licenses.useAICustomConfigs()
mockChatGPTResponse("This is a test")
// We expect a non-0 AI usage here because it goes through the @budibase/pro
// path, because we've enabled Budibase AI. The exact value depends on a
// calculation we use to approximate cost. This uses Budibase's OpenAI API
// key, so we charge users for it.
const result = await withEnv({ SELF_HOSTED: false }, () =>
const result = await withEnv({ SELF_HOSTED: false }, async () =>
expectAIUsage(14, () =>
createAutomationBuilder(config)
.onAppAction()

View File

@ -377,6 +377,10 @@ class LinkController {
linkedField.subtype = field.subtype
}
if (field.aiGenerated) {
linkedField.aiGenerated = field.aiGenerated
}
// check the linked table to make sure we aren't overwriting an existing column
const existingSchema = linkedTable.schema[field.fieldName]
if (

View File

@ -0,0 +1,2 @@
export * from "./table"
export * from "./rows"

View File

@ -0,0 +1,127 @@
import { FieldSchema, FieldType, TableSchema } from "@budibase/types"
import sdk from "../../.."
import { uploadFile, uploadUrl } from "../../../../utilities"
export async function generateRows(
data: Record<string, Record<string, any>[]>,
userId: string,
tables: Record<string, { _id: string; schema: TableSchema }>
) {
const createdData: Record<string, Record<string, string>> = {}
const toUpdateLinks: {
tableId: string
rowId: string
data: Record<string, { rowId: string[]; tableId: string }>
}[] = []
const rowPromises = []
for (const tableName of Object.keys(data)) {
const table = tables[tableName]
const linksOverride: Record<string, null> = {}
for (const field of Object.values(table.schema).filter(
f => f.type === FieldType.LINK
)) {
linksOverride[field.name] = null
}
const attachmentColumns = Object.values(table.schema).filter(f =>
[FieldType.ATTACHMENTS, FieldType.ATTACHMENT_SINGLE].includes(f.type)
)
rowPromises.push(
...data[tableName].map(async entry => {
await processAttachments(entry, attachmentColumns)
const tableId = table._id!
const createdRow = await sdk.rows.save(
tableId,
{
...entry,
...linksOverride,
_id: undefined,
},
userId
)
createdData[tableId] ??= {}
createdData[tableId][entry._id] = createdRow.row._id!
const overridenLinks = Object.keys(linksOverride).reduce<
Record<string, { rowId: string[]; tableId: string }>
>((acc, l) => {
if (entry[l]) {
acc[l] = {
tableId: (table.schema[l] as any).tableId,
rowId: entry[l],
}
}
return acc
}, {})
if (Object.keys(overridenLinks).length) {
toUpdateLinks.push({
tableId: createdRow.table._id!,
rowId: createdRow.row._id!,
data: overridenLinks,
})
}
})
)
}
await Promise.all(rowPromises)
await Promise.all(
toUpdateLinks.map(async data => {
const persistedRow = await sdk.rows.find(data.tableId, data.rowId)
const updatedLinks = Object.keys(data.data).reduce<Record<string, any>>(
(acc, d) => {
acc[d] = [
...(persistedRow[d] || []),
...data.data[d].rowId.map(
rid => createdData[data.data[d].tableId][rid]
),
]
return acc
},
{}
)
await sdk.rows.save(
data.tableId,
{
...persistedRow,
...updatedLinks,
},
userId,
{ updateAIColumns: false }
)
})
)
}
async function processAttachments(
entry: Record<string, any>,
attachmentColumns: FieldSchema[]
) {
function processAttachment(value: any) {
if (typeof value === "object") {
return uploadFile(value)
}
return uploadUrl(value)
}
for (const column of attachmentColumns) {
if (!Array.isArray(entry[column.name])) {
entry[column.name] = await processAttachment(entry[column.name])
} else {
entry[column.name] = await Promise.all(
entry[column.name].map((attachment: any) =>
processAttachment(attachment)
)
)
}
}
}

View File

@ -0,0 +1,86 @@
import {
FieldType,
GenerateTablesResponse,
INTERNAL_TABLE_SOURCE_ID,
TableSchema,
TableSourceType,
} from "@budibase/types"
import sdk from "../../.."
import { helpers } from "@budibase/shared-core"
export async function generateTables(
tables: { name: string; primaryDisplay: string; schema: TableSchema }[]
) {
const createdTables: GenerateTablesResponse["createdTables"] = []
const tableIds: Record<string, string> = {}
try {
for (const table of tables) {
for (const linkField of Object.values(table.schema).filter(
f => f.type === FieldType.LINK
)) {
if (!tables.find(t => t.name === linkField.tableId)) {
throw `Table ${linkField.tableId} not found in the json response.`
}
}
}
const existingTableNames = (await sdk.tables.getAllInternalTables()).map(
t => t.name
)
for (const table of tables) {
const name = helpers.getSequentialName(existingTableNames, table.name, {
separator: " ",
})
const createdTable = await sdk.tables.create({
...table,
name,
schema: {},
primaryDisplay: undefined,
sourceType: TableSourceType.INTERNAL,
sourceId: INTERNAL_TABLE_SOURCE_ID,
type: "table",
aiGenerated: true,
})
createdTables.push({ id: createdTable._id!, name: table.name })
tableIds[table.name] = createdTable._id!
}
for (const table of tables) {
for (const field of Object.values(table.schema)) {
if (field.type === FieldType.LINK) {
field.tableId = tableIds[field.tableId]
} else if (field.type === FieldType.FORMULA) {
field.formula = `{{ js "${btoa(field.formula)}" }}`
}
}
}
for (const table of tables) {
const storedTable = await sdk.tables.getTable(tableIds[table.name])
const schema = {
...storedTable.schema,
...table.schema,
}
for (const field of Object.keys(schema)) {
schema[field].aiGenerated = true
}
await sdk.tables.update({
...storedTable,
schema,
primaryDisplay: table.primaryDisplay,
})
}
} catch (e) {
const tables = await sdk.tables.getTables(createdTables.map(t => t.id))
await Promise.all(tables.map(sdk.tables.internal.destroy))
throw e
}
return createdTables
}

View File

@ -0,0 +1 @@
export * as helpers from "./helpers"

View File

@ -11,6 +11,7 @@ import {
Row,
RestConfig,
SourceName,
INTERNAL_TABLE_SOURCE_ID,
} from "@budibase/types"
import { cloneDeep } from "lodash/fp"
import { getEnvironmentVariables } from "../../utils"
@ -51,7 +52,7 @@ export async function fetch(opts?: {
)
const internal = internalTables.rows.reduce((acc: any, row: Row) => {
const sourceId = row.doc.sourceId || "bb_internal"
const sourceId = row.doc.sourceId || INTERNAL_TABLE_SOURCE_ID
acc[sourceId] = acc[sourceId] || []
acc[sourceId].push(row.doc)
return acc

View File

@ -15,7 +15,8 @@ import { helpers } from "@budibase/shared-core"
export async function save(
tableOrViewId: string,
inputs: Row,
userId: string | undefined
userId: string | undefined,
opts?: { updateAIColumns: boolean }
) {
const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId)
inputs.tableId = tableId
@ -57,7 +58,10 @@ export async function save(
table,
})) as Row
return finaliseRow(source, row, { updateFormula: true })
return finaliseRow(source, row, {
updateFormula: true,
updateAIColumns: opts?.updateAIColumns || true,
})
}
export async function find(sourceId: string, rowId: string): Promise<Row> {

View File

@ -35,9 +35,10 @@ function pickApi(tableOrViewId: string) {
export async function save(
sourceId: string,
row: Row,
userId: string | undefined
userId: string | undefined,
opts?: { updateAIColumns: boolean }
) {
return pickApi(sourceId).save(sourceId, row, userId)
return pickApi(sourceId).save(sourceId, row, userId, opts)
}
export async function find(sourceId: string, rowId: string) {

View File

@ -144,7 +144,7 @@ export async function save(
}
// update linked rows
const linkResp: any = await updateLinks({
const linkResp = await updateLinks({
eventType: oldTable ? EventType.TABLE_UPDATED : EventType.TABLE_SAVE,
table: table,
oldTable: oldTable,

View File

@ -14,6 +14,7 @@ import * as rowActions from "./app/rowActions"
import * as screens from "./app/screens"
import * as common from "./app/common"
import * as oauth2 from "./app/oauth2"
import * as ai from "./app/ai"
const sdk = {
backups,
@ -32,6 +33,7 @@ const sdk = {
rowActions,
common,
oauth2,
ai,
}
// default export for TS

View File

@ -5,6 +5,8 @@ import {
GenerateCronResponse,
GenerateJsRequest,
GenerateJsResponse,
GenerateTablesRequest,
GenerateTablesResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
import { constants } from "@budibase/backend-core"
@ -44,4 +46,16 @@ export class AIAPI extends TestAPI {
expectations,
})
}
generateTables = async (
req: GenerateTablesRequest,
expectations?: Expectations
): Promise<GenerateTablesResponse> => {
const headers: Record<string, string> = {}
return await this._post<GenerateTablesResponse>(`/api/ai/tables`, {
body: req,
headers,
expectations,
})
}
}

View File

@ -0,0 +1,85 @@
import fs from "fs"
import path from "path"
import { pipeline } from "stream"
import { promisify } from "util"
import * as uuid from "uuid"
import fetch from "node-fetch"
import { context, objectStore } from "@budibase/backend-core"
import { Upload } from "@budibase/types"
import { ObjectStoreBuckets } from "../constants"
function getTmpPath() {
const tmpPath = path.join(objectStore.budibaseTempDir(), "ai-downloads")
if (!fs.existsSync(tmpPath)) {
fs.mkdirSync(tmpPath)
}
return tmpPath
}
export async function uploadUrl(url: string): Promise<Upload | undefined> {
try {
const res = await fetch(url)
const extension = [...res.url.split(".")].pop()!.split("?")[0]
const destination = path.resolve(getTmpPath(), `${uuid.v4()}${extension}`)
const fileStream = fs.createWriteStream(destination, { flags: "wx" })
await promisify(pipeline)(res.body, fileStream)
const processedFileName = path.basename(destination)
const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}`
const response = await objectStore.upload({
bucket: ObjectStoreBuckets.APPS,
filename: s3Key,
path: destination,
type: "image/jpeg",
})
return {
size: fileStream.bytesWritten,
name: processedFileName,
url: await objectStore.getAppFileUrl(s3Key),
extension,
key: response.Key!,
}
} catch (e) {
console.error("Error downloading file", e)
return
}
}
export async function uploadFile(file: {
fileName: string
extension: string
content: string
}): Promise<Upload> {
const destination = path.resolve(
getTmpPath(),
`${file.fileName}${file.extension}`
)
fs.writeFileSync(destination, file.content)
const processedFileName = path.basename(destination)
const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}`
const response = await objectStore.upload({
bucket: ObjectStoreBuckets.APPS,
filename: s3Key,
path: destination,
type: "text/plain",
})
return {
size: fs.readFileSync(destination).byteLength,
name: processedFileName,
url: await objectStore.getAppFileUrl(s3Key),
extension: file.extension,
key: response.Key!,
}
}

View File

@ -1,3 +1,4 @@
export * from "./fileUtils"
import env from "../environment"
import { context } from "@budibase/backend-core"
import { generateMetadataID } from "../db/utils"

View File

@ -0,0 +1,113 @@
/**
* Duplicates a name with respect to a collection of existing names
* e.g.
* name all names result
* ------ ----------- --------
* ("foo") ["foo"] "foo 1"
* ("foo") ["foo", "foo 1"] "foo 2"
* ("foo 1") ["foo", "foo 1"] "foo 2"
* ("foo") ["foo", "foo 2"] "foo 1"
*
* Repl
*/
export const duplicateName = (name: string, allNames: string[]) => {
const duplicatePattern = new RegExp(`\\s(\\d+)$`)
const baseName = name.split(duplicatePattern)[0]
const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`)
// get the sequence from matched names
const sequence: number[] = []
allNames.filter(n => {
if (n === baseName) {
return true
}
const match = n.match(isDuplicate)
if (match) {
sequence.push(parseInt(match[1]))
return true
}
return false
})
sequence.sort((a, b) => a - b)
// get the next number in the sequence
let number
if (sequence.length === 0) {
number = 1
} else {
// get the next number in the sequence
for (let i = 0; i < sequence.length; i++) {
if (sequence[i] !== i + 1) {
number = i + 1
break
}
}
if (!number) {
number = sequence.length + 1
}
}
return `${baseName} ${number}`
}
/**
* More flexible alternative to the above function, which handles getting the
* next sequential name from an array of existing items while accounting for
* any type of prefix, and being able to deeply retrieve that name from the
* existing item array.
*
* Examples with a prefix of "foo":
* [] => "foo"
* ["foo"] => "foo2"
* ["foo", "foo6"] => "foo7"
*
* Examples with a prefix of "foo " (space at the end):
* [] => "foo"
* ["foo"] => "foo 2"
* ["foo", "foo 6"] => "foo 7"
*
* @param items the array of existing items
* @param prefix the string prefix of each name, including any spaces desired
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = <T extends any>(
items: T[] | null,
prefix: string | null,
{
getName,
numberFirstItem,
separator = "",
}: {
getName?: (item: T) => string
numberFirstItem?: boolean
separator?: string
} = {}
) => {
if (!prefix?.length) {
return ""
}
const trimmedPrefix = prefix.trim()
const firstName = numberFirstItem ? `${prefix}1` : trimmedPrefix
if (!items?.length) {
return firstName
}
let max = 0
items.forEach(item => {
const name = getName?.(item) ?? item
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}
const split = name.split(trimmedPrefix)
if (split.length !== 2) {
return
}
if (split[1].trim() === "") {
split[1] = "1"
}
const num = parseInt(split[1])
if (num > max) {
max = num
}
})
return max === 0 ? firstName : `${prefix}${separator}${max + 1}`
}

View File

@ -7,3 +7,4 @@ export * as schema from "./schema"
export * as views from "./views"
export * as roles from "./roles"
export * as lists from "./lists"
export * from "./duplicate"

View File

@ -1,4 +1,3 @@
import { expect, describe, it } from "vitest"
import { duplicateName, getSequentialName } from "../duplicate"
describe("duplicate", () => {

View File

@ -174,3 +174,13 @@ export function processSearchFilters(
],
}
}
export function toMap<TKey extends keyof TItem, TItem extends {}>(
key: TKey,
list: TItem[]
): Record<string, TItem> {
return list.reduce<Record<string, TItem>>((result, item) => {
result[item[key] as string] = item
return result
}, {})
}

View File

@ -1,3 +1,4 @@
import openai from "openai"
import { EnrichedBinding } from "../../ui"
export interface Message {
@ -5,9 +6,7 @@ export interface Message {
content: string
}
export enum StructuredOutput {}
export type ResponseFormat = "text" | "json" | StructuredOutput
export type ResponseFormat = "text" | "json" | openai.ResponseFormatJSONSchema
export interface ChatCompletionRequest {
messages: Message[]
@ -35,3 +34,11 @@ export interface GenerateCronRequest {
export interface GenerateCronResponse {
message?: string
}
export interface GenerateTablesRequest {
prompt: string
}
export interface GenerateTablesResponse {
createdTables: { id: string; name: string }[]
}

View File

@ -0,0 +1,3 @@
export interface OverrideFeatureFlagRequest {
flags: Record<string, boolean>
}

View File

@ -5,6 +5,7 @@ export * from "./backup"
export * from "./component"
export * from "./datasource"
export * from "./deployment"
export * from "./features"
export * from "./integration"
export * from "./layout"
export * from "./metadata"

View File

@ -7,3 +7,7 @@ export interface SessionCookie {
sessionId: string
userId: string
}
export interface FeatureFlagCookie {
flags: Record<string, boolean>
}

View File

@ -4,3 +4,10 @@ export interface APIError {
error?: any
validationErrors?: any
}
export enum ErrorCode {
USAGE_LIMIT_EXCEEDED = "usage_limit_exceeded",
FEATURE_DISABLED = "feature_disabled",
INVALID_API_KEY = "invalid_api_key",
HTTP = "http",
}

View File

@ -14,6 +14,7 @@ export type FieldSubType =
| AutoFieldSubType
| JsonFieldSubType
| BBReferenceFieldSubType
| AttachmentSubType
export enum AutoFieldSubType {
CREATED_BY = "createdBy",
@ -39,6 +40,10 @@ export enum BBReferenceFieldSubType {
USERS = "users",
}
export enum AttachmentSubType {
IMAGE = "image",
}
export type SupportedSqlTypes =
| FieldType.STRING
| FieldType.BARCODEQR

View File

@ -2,6 +2,7 @@
// column size, position and whether it can be viewed
import { FieldType, FormulaResponseType } from "../row"
import {
AttachmentSubType,
AutoFieldSubType,
AutoReason,
BBReferenceFieldSubType,
@ -142,8 +143,15 @@ export interface BBReferenceSingleFieldMetadata
default?: string
}
export interface AttachmentFieldMetadata extends BaseFieldSchema {
export interface AttachmentFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.ATTACHMENTS
subtype?: AttachmentSubType
}
export interface SingleAttachmentFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.ATTACHMENT_SINGLE
subtype?: AttachmentSubType
}
export interface FieldConstraints {
@ -209,6 +217,7 @@ export interface BaseFieldSchema extends UIFieldMetadata {
subtype?: never
// added when enriching nested JSON fields into schema
nestedJSON?: boolean
aiGenerated?: boolean
}
interface OtherFieldMetadata extends BaseFieldSchema {
@ -246,6 +255,7 @@ export type FieldSchema =
| BBReferenceFieldMetadata
| JsonFieldMetadata
| AttachmentFieldMetadata
| SingleAttachmentFieldMetadata
| BBReferenceSingleFieldMetadata
| ArrayFieldMetadata
| OptionsFieldMetadata

View File

@ -26,6 +26,7 @@ export interface Table extends Document {
indexes?: { [key: string]: any }
created?: boolean
rowHeight?: number
aiGenerated?: boolean
}
export interface TableRequest extends Table {

View File

@ -122,7 +122,6 @@ export type AIProvider =
export interface ProviderConfig {
provider: AIProvider
isDefault: boolean
isBudibaseAI?: boolean
name: string
active: boolean
baseUrl?: string

View File

@ -94,8 +94,7 @@ export type AIColumnSchema =
export interface LLMConfigOptions {
model: string
apiKey: string
measureUsage: boolean
apiKey?: string
}
export interface LLMProviderConfig extends LLMConfigOptions {

View File

@ -1,4 +1,5 @@
export enum FeatureFlag {
DEBUG_UI = "DEBUG_UI",
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
AI_JS_GENERATION = "AI_JS_GENERATION",
AI_TABLE_GENERATION = "AI_TABLE_GENERATION",
@ -14,6 +15,8 @@ export const FeatureFlagDefaults: Record<FeatureFlag, boolean> = {
// Account-portal
[FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false,
[FeatureFlag.DEBUG_UI]: false,
}
export type FeatureFlags = typeof FeatureFlagDefaults

View File

@ -9,6 +9,7 @@ import {
events,
objectStore,
tenancy,
BadRequestError,
} from "@budibase/backend-core"
import { checkAnyUserExists } from "../../../utilities/users"
import {
@ -31,14 +32,12 @@ import {
OIDCConfigs,
OIDCLogosConfig,
PASSWORD_REPLACEMENT,
QuotaUsageType,
SaveConfigRequest,
SaveConfigResponse,
SettingsBrandingConfig,
SettingsInnerConfig,
SSOConfig,
SSOConfigType,
StaticQuotaName,
UploadConfigFileResponse,
UserCtx,
} from "@budibase/types"
@ -52,7 +51,6 @@ const getEventFns = async (config: Config, existing?: Config) => {
fns.push(events.email.SMTPCreated)
} else if (isAIConfig(config)) {
fns.push(() => events.ai.AIConfigCreated)
fns.push(() => pro.quotas.addCustomAIConfig())
} else if (isGoogleConfig(config)) {
fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE))
if (config.config.activated) {
@ -91,12 +89,6 @@ const getEventFns = async (config: Config, existing?: Config) => {
fns.push(events.email.SMTPUpdated)
} else if (isAIConfig(config)) {
fns.push(() => events.ai.AIConfigUpdated)
if (
Object.keys(existing.config).length > Object.keys(config.config).length
) {
fns.push(() => pro.quotas.removeCustomAIConfig())
}
fns.push(() => pro.quotas.addCustomAIConfig())
} else if (isGoogleConfig(config)) {
fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE))
if (!existing.config.activated && config.config.activated) {
@ -219,14 +211,30 @@ async function verifyOIDCConfig(config: OIDCConfigs) {
await verifySSOConfig(ConfigType.OIDC, config.configs[0])
}
export async function verifyAIConfig(
configToSave: AIInnerConfig,
existingConfig: AIConfig
export async function processAIConfig(
newConfig: AIInnerConfig,
existingConfig: AIInnerConfig
) {
// ensure that the redacted API keys are not overwritten in the DB
for (const uuid in existingConfig.config) {
if (configToSave[uuid]?.apiKey === PASSWORD_REPLACEMENT) {
configToSave[uuid].apiKey = existingConfig.config[uuid].apiKey
for (const key in existingConfig) {
if (newConfig[key]?.apiKey === PASSWORD_REPLACEMENT) {
newConfig[key].apiKey = existingConfig[key].apiKey
}
}
let numBudibaseAI = 0
for (const config of Object.values(newConfig)) {
if (config.provider === "BudibaseAI") {
numBudibaseAI++
if (numBudibaseAI > 1) {
throw new BadRequestError("Only one Budibase AI provider is allowed")
}
} else {
if (!config.apiKey) {
throw new BadRequestError(
`API key is required for provider ${config.provider}`
)
}
}
}
}
@ -246,7 +254,6 @@ export async function save(
}
try {
// verify the configuration
switch (type) {
case ConfigType.SMTP:
await email.verifyConfig(config)
@ -262,7 +269,7 @@ export async function save(
break
case ConfigType.AI:
if (existingConfig) {
await verifyAIConfig(config, existingConfig)
await processAIConfig(config, existingConfig.config)
}
break
}
@ -354,7 +361,7 @@ export async function find(ctx: UserCtx<void, FindConfigResponse>) {
if (scopedConfig) {
await handleConfigType(type, scopedConfig)
} else if (type === ConfigType.AI) {
scopedConfig = { config: {} } as AIConfig
scopedConfig = { type: ConfigType.AI, config: {} }
await handleAIConfig(scopedConfig)
} else {
// If no config found and not AI type, just return an empty body
@ -560,13 +567,6 @@ export async function destroy(ctx: UserCtx<void, DeleteConfigResponse>) {
try {
await db.remove(id, rev)
await cache.destroy(cache.CacheKey.CHECKLIST)
if (id === configs.generateConfigID(ConfigType.AI)) {
await pro.quotas.set(
StaticQuotaName.AI_CUSTOM_CONFIGS,
QuotaUsageType.STATIC,
0
)
}
ctx.body = { message: "Config deleted successfully" }
} catch (err: any) {
ctx.throw(err.status, err)

View File

@ -120,7 +120,6 @@ export async function getSelf(ctx: UserCtx<void, GetGlobalSelfResponse>) {
? {
provider: llmConfig.provider,
model: llmConfig.model,
measureUsage: llmConfig.measureUsage,
}
: undefined

View File

@ -1,6 +1,21 @@
import { verifyAIConfig } from "../configs"
import { TestConfiguration, structures } from "../../../../tests"
import { AIInnerConfig } from "@budibase/types"
import { TestConfiguration } from "../../../../tests"
import { AIConfig, ConfigType } from "@budibase/types"
import { configs, context } from "@budibase/backend-core"
const BASE_CONFIG: AIConfig = {
type: ConfigType.AI,
config: {
ai: {
provider: "OpenAI",
isDefault: false,
name: "Test",
active: true,
defaultModel: "gpt4",
apiKey: "myapikey",
baseUrl: "https://api.example.com",
},
},
}
describe("Global configs controller", () => {
const config = new TestConfiguration()
@ -13,40 +28,70 @@ describe("Global configs controller", () => {
await config.afterAll()
})
it("Should strip secrets when pulling AI config", async () => {
const data = structures.configs.ai()
await config.api.configs.saveConfig(data)
it("should strip secrets when pulling AI config", async () => {
await config.api.configs.saveConfig(BASE_CONFIG)
const response = await config.api.configs.getAIConfig()
expect(response.body.config).toEqual({
ai: {
active: true,
apiKey: "--secret-value--",
baseUrl: "https://api.example.com",
defaultModel: "gpt4",
isDefault: false,
name: "Test",
provider: "OpenAI",
},
expect(response.config.ai.apiKey).toEqual("--secret-value--")
})
it("should not update existing secrets when updating an existing AI Config", async () => {
await config.api.configs.saveConfig(BASE_CONFIG)
const savedConfig = await config.api.configs.getAIConfig()
delete savedConfig._id
delete savedConfig._rev
delete savedConfig.createdAt
delete savedConfig.updatedAt
await config.api.configs.saveConfig(savedConfig)
await context.doInTenant(config.tenantId, async () => {
const aiConfig = await configs.getAIConfig()
expect(aiConfig!.config.ai.apiKey).toEqual(BASE_CONFIG.config.ai.apiKey)
})
})
it("Should not update existing secrets when updating an existing AI Config", async () => {
const data = structures.configs.ai()
await config.api.configs.saveConfig(data)
const newConfig: AIInnerConfig = {
ai: {
provider: "OpenAI",
isDefault: true,
apiKey: "--secret-value--",
name: "MyConfig",
active: true,
defaultModel: "gpt4",
it("should allow BudibaseAI to save without an apiKey", async () => {
await config.api.configs.saveConfig({
type: ConfigType.AI,
config: {
ai: {
name: "Budibase AI",
active: true,
provider: "BudibaseAI",
isDefault: true,
},
},
}
})
await verifyAIConfig(newConfig, data)
// should be unchanged
expect(newConfig.ai.apiKey).toEqual("myapikey")
const aiConfig = await config.api.configs.getAIConfig()
expect(aiConfig.config.ai).toEqual({
name: "Budibase AI",
provider: "BudibaseAI",
active: true,
isDefault: true,
})
})
it("should not allow OpenAI to save without an apiKey", async () => {
await config.api.configs.saveConfig(
{
type: ConfigType.AI,
config: {
ai: {
name: "OpenAI",
active: true,
provider: "OpenAI",
isDefault: true,
},
},
},
{
status: 400,
body: {
message: /API key is required for provider OpenAI/,
},
}
)
})
})

View File

@ -16,6 +16,7 @@ import {
DeleteInviteUsersRequest,
DeleteInviteUsersResponse,
DeleteUserResponse,
ErrorCode,
FetchUsersResponse,
FindUserResponse,
GetUserInvitesResponse,
@ -42,7 +43,6 @@ import {
import {
users,
cache,
ErrorCode,
events,
platform,
tenancy,

View File

@ -130,6 +130,7 @@ const router: Router = new Router()
router
.use(middleware.errorHandling)
.use(middleware.featureFlagCookie)
.use(
compress({
threshold: 2048,

View File

@ -68,16 +68,16 @@ function scimValidation() {
function aiValidation() {
// prettier-ignore
return Joi.object().pattern(
Joi.string(),
Joi.object({
provider: Joi.string().required(),
isDefault: Joi.boolean().required(),
name: Joi.string().required(),
active: Joi.boolean().required(),
baseUrl: Joi.string().optional().allow("", null),
apiKey: Joi.string().required(),
defaultModel: Joi.string().optional(),
}).required()
Joi.string(),
Joi.object({
provider: Joi.string().required(),
isDefault: Joi.boolean().required(),
name: Joi.string().required(),
active: Joi.boolean().required(),
baseUrl: Joi.string().optional().allow("", null),
apiKey: Joi.string().optional(),
defaultModel: Joi.string().optional(),
}).required()
)
}

View File

@ -28,10 +28,7 @@ describe("configs", () => {
_rev,
}
const res = await config.api.configs.saveConfig(data)
return {
...data,
...res.body,
}
return { ...data, ...res }
}
const saveSettingsConfig = async (

View File

@ -1,3 +1,8 @@
import {
AIConfig,
SaveConfigRequest,
SaveConfigResponse,
} from "@budibase/types"
import { TestAPI } from "./base"
export class ConfigAPI extends TestAPI {
@ -17,21 +22,44 @@ export class ConfigAPI extends TestAPI {
.expect("Content-Type", /json/)
}
getAIConfig = () => {
return this.request
getAIConfig = async () => {
const resp = await this.request
.get(`/api/global/configs/ai`)
.set(this.config.defaultHeaders())
.expect(200)
.expect("Content-Type", /json/)
return resp.body as AIConfig
}
saveConfig = (data: any) => {
return this.request
saveConfig = async (
data: SaveConfigRequest,
expectations?: { status?: number; body?: Record<string, string | RegExp> }
) => {
const { status = 200, body } = expectations || {}
const resp = await this.request
.post(`/api/global/configs`)
.send(data)
.set(this.config.defaultHeaders())
.expect(200)
.expect("Content-Type", /json/)
if (resp.status !== status) {
throw new Error(
`Expected status ${status}, got ${resp.status}: ${resp.text}`
)
}
if (body) {
for (const [key, value] of Object.entries(body)) {
if (typeof value === "string") {
expect(resp.body[key]).toEqual(value)
} else if (value instanceof RegExp) {
expect(resp.body[key]).toMatch(value)
}
}
}
return resp.body as SaveConfigResponse
}
OIDCCallback = (configId: string, preAuthRes: any) => {

View File

@ -5,7 +5,6 @@ import {
SMTPConfig,
GoogleConfig,
OIDCConfig,
AIConfig,
} from "@budibase/types"
export function oidc(conf?: any): OIDCConfig {
@ -82,20 +81,3 @@ export function settings(conf?: any): SettingsConfig {
},
}
}
export function ai(): AIConfig {
return {
type: ConfigType.AI,
config: {
ai: {
provider: "OpenAI",
isDefault: false,
name: "Test",
active: true,
defaultModel: "gpt4",
apiKey: "myapikey",
baseUrl: "https://api.example.com",
},
},
}
}