Merge branch 'master' of github.com:Budibase/budibase into remove-tour
This commit is contained in:
commit
1f2ed51ca3
|
@ -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)
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -24,5 +24,6 @@ export type ContextMap = {
|
|||
featureFlagCache?: {
|
||||
[key: string]: Record<string, boolean>
|
||||
}
|
||||
featureFlagOverrides?: Record<string, boolean>
|
||||
viewToTableCache?: Record<string, Table>
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}: </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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
$: isPremiumOrAbove = [
|
||||
Constants.PlanType.PREMIUM,
|
||||
Constants.PlanType.PREMIUM_PLUS,
|
||||
Constants.PlanType.ENTERPRISE,
|
||||
Constants.PlanType.ENTERPRISE_BASIC_TRIAL,
|
||||
Constants.PlanType.ENTERPRISE_BASIC,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -372,6 +372,7 @@
|
|||
<CodeEditor
|
||||
value={jsValue ? decodeJSBinding(jsValue) : ""}
|
||||
on:change={onChangeJSValue}
|
||||
on:ai_suggestion={() => (sidePanel = "Evaluation")}
|
||||
{completions}
|
||||
{bindings}
|
||||
{validations}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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: "",
|
||||
},
|
||||
}
|
|
@ -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: "",
|
||||
},
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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",
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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: () => {},
|
||||
})
|
||||
},
|
||||
})
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 }
|
||||
|
|
|
@ -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],
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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 user’s 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./table"
|
||||
export * from "./rows"
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * as helpers from "./helpers"
|
|
@ -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
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!,
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./fileUtils"
|
||||
import env from "../environment"
|
||||
import { context } from "@budibase/backend-core"
|
||||
import { generateMetadataID } from "../db/utils"
|
||||
|
|
|
@ -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}`
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { expect, describe, it } from "vitest"
|
||||
import { duplicateName, getSequentialName } from "../duplicate"
|
||||
|
||||
describe("duplicate", () => {
|
|
@ -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
|
||||
}, {})
|
||||
}
|
||||
|
|
|
@ -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 }[]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export interface OverrideFeatureFlagRequest {
|
||||
flags: Record<string, boolean>
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -7,3 +7,7 @@ export interface SessionCookie {
|
|||
sessionId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface FeatureFlagCookie {
|
||||
flags: Record<string, boolean>
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -26,6 +26,7 @@ export interface Table extends Document {
|
|||
indexes?: { [key: string]: any }
|
||||
created?: boolean
|
||||
rowHeight?: number
|
||||
aiGenerated?: boolean
|
||||
}
|
||||
|
||||
export interface TableRequest extends Table {
|
||||
|
|
|
@ -122,7 +122,6 @@ export type AIProvider =
|
|||
export interface ProviderConfig {
|
||||
provider: AIProvider
|
||||
isDefault: boolean
|
||||
isBudibaseAI?: boolean
|
||||
name: string
|
||||
active: boolean
|
||||
baseUrl?: string
|
||||
|
|
|
@ -94,8 +94,7 @@ export type AIColumnSchema =
|
|||
|
||||
export interface LLMConfigOptions {
|
||||
model: string
|
||||
apiKey: string
|
||||
measureUsage: boolean
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface LLMProviderConfig extends LLMConfigOptions {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -120,7 +120,6 @@ export async function getSelf(ctx: UserCtx<void, GetGlobalSelfResponse>) {
|
|||
? {
|
||||
provider: llmConfig.provider,
|
||||
model: llmConfig.model,
|
||||
measureUsage: llmConfig.measureUsage,
|
||||
}
|
||||
: undefined
|
||||
|
||||
|
|
|
@ -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/,
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
DeleteInviteUsersRequest,
|
||||
DeleteInviteUsersResponse,
|
||||
DeleteUserResponse,
|
||||
ErrorCode,
|
||||
FetchUsersResponse,
|
||||
FindUserResponse,
|
||||
GetUserInvitesResponse,
|
||||
|
@ -42,7 +43,6 @@ import {
|
|||
import {
|
||||
users,
|
||||
cache,
|
||||
ErrorCode,
|
||||
events,
|
||||
platform,
|
||||
tenancy,
|
||||
|
|
|
@ -130,6 +130,7 @@ const router: Router = new Router()
|
|||
|
||||
router
|
||||
.use(middleware.errorHandling)
|
||||
.use(middleware.featureFlagCookie)
|
||||
.use(
|
||||
compress({
|
||||
threshold: 2048,
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue