rough pass on AI platform CRUD

This commit is contained in:
Martin McKeaveney 2024-09-02 20:16:37 +01:00
parent 1a57e37d38
commit 99035ad8ae
10 changed files with 220 additions and 57 deletions

View File

@ -1,4 +1,5 @@
import { import {
AIConfig, AIInnerConfig,
Config, Config,
ConfigType, ConfigType,
GoogleConfig, GoogleConfig,
@ -17,6 +18,7 @@ import { DocumentType, SEPARATOR } from "../constants"
import { CacheKey, TTL, withCache } from "../cache" import { CacheKey, TTL, withCache } from "../cache"
import * as context from "../context" import * as context from "../context"
import env from "../environment" import env from "../environment"
import { getConfigParams } from "@budibase/server/src/migrations/functions/backfill/global/configs"
// UTILS // UTILS
@ -254,3 +256,42 @@ export async function getSCIMConfig(): Promise<SCIMInnerConfig | undefined> {
const config = await getConfig<SCIMConfig>(ConfigType.SCIM) const config = await getConfig<SCIMConfig>(ConfigType.SCIM)
return config?.config return config?.config
} }
// AI
// TODO: Can we assume that you are licensed when you hit this endpoint?
export async function getAIConfig(): Promise<AIConfig | undefined> {
if (!env.SELF_HOSTED) {
// always use the env vars in cloud
// TODO: Licensing stuff - make this right
if (env.OPENAI_API_KEY) {
return getDefaultBudibaseAIConfig()
}
}
// prefer the config in self-host
let config = await getConfig<AIConfig>(ConfigType.AI)
// fallback to env vars
if (!config || !config.activated) {
config = getDefaultBudibaseAIConfig()
}
return config
}
export function getDefaultBudibaseAIConfig(): AIInnerConfig | undefined {
if (env.OPENAI_API_KEY) {
return {
provider: "",
isDefault: true,
name: "Budibase AI",
active: true,
baseUrl: "",
apiKey: env.OPENAI_API_KEY,
// TODO: should be enum
defaultModel: ""
}
}
}

View File

@ -5,15 +5,14 @@
Input, Input,
Select, Select,
Toggle, Toggle,
Dropzone,
Body, Body,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import { ConfigMap } from "./constants"
import { plugins } from "stores/portal" import { API } from "api"
import { PluginSource } from "constants"
const options = [ // TODO: Update these
const providers = [
"OpenAI", "OpenAI",
"Anthropic", "Anthropic",
"Together AI", "Together AI",
@ -21,21 +20,69 @@
"Custom" "Custom"
] ]
let source = PluginSource.GITHUB const models = [
let dynamicValues = {} "gpt4o-mini",
]
let name let formValues = {}
let baseUrl
let apiKey
let active
let isDefault
let validation let validation
$: {
const { provider, model, name, apiKey } = formValues
validation = provider && model && name && apiKey
}
function prefillConfig(evt) {
const provider = evt.detail
// grab the preset config from the constants for that provider and fill it in
if (ConfigMap[provider]) {
formValues = {
...formValues,
...ConfigMap[provider]
}
} else {
formValues = {
provider
}
}
}
async function saveConfig() {
try {
const savedConfig = await API.saveConfig(formValues)
formValues._rev = savedConfig._rev
formValues._id = savedConfig._id
notifications.success(`Configuration saved`)
} catch (error) {
notifications.error(
`Failed to save AI Configuration, reason: ${error?.message || "Unknown"}`
)
}
}
async function deleteConfig() {
// Delete a configuration
try {
// await API.deleteConfig({
// id: smtpConfig._id,
// rev: smtpConfig._rev,
// })
notifications.success(`Deleted config`)
} catch (error) {
notifications.error(
`Failed to clear email settings, reason: ${error?.message || "Unknown"}`
)
}
}
</script> </script>
<ModalContent <ModalContent
confirmText={"Save"} confirmText={"Save"}
onConfirm={() => {}} cancelText={"Delete"}
onConfirm={saveConfig}
onCancel={deleteConfig}
disabled={!validation} disabled={!validation}
size="M" size="M"
title="Custom AI Configuration" title="Custom AI Configuration"
@ -43,39 +90,40 @@
<div class="form-row"> <div class="form-row">
<Label size="M">Provider</Label> <Label size="M">Provider</Label>
<Select <Select
placeholder={null} placeholder={null}
bind:value={source} bind:value={formValues.provider}
options={options} options={providers}
on:change={prefillConfig}
/> />
</div> </div>
<div class="form-row"> <div class="form-row">
<Label size="M">Model</Label> <Label size="M">Model</Label>
<Select <Select
placeholder={null} placeholder={null}
bind:value={source} bind:value={formValues.model}
options={Object.values(PluginSource)} options={models}
/> />
</div> </div>
<div class="form-row"> <div class="form-row">
<Label size="M">Name</Label> <Label size="M">Name</Label>
<Input placeholder={"Test 1"} bind:value={name} /> <Input placeholder={"Test 1"} bind:value={formValues.name}/>
</div> </div>
<div class="form-row"> <div class="form-row">
<Label size="M">Base URL</Label> <Label size="M">Base URL</Label>
<Input placeholder={"www.google.com"} bind:value={baseUrl} /> <Input placeholder={"www.google.com"} bind:value={formValues.baseUrl}/>
</div> </div>
<div class="form-row"> <div class="form-row">
<Label size="M">API Key</Label> <Label size="M">API Key</Label>
<Input bind:value={apiKey} /> <Input bind:value={formValues.apiKey}/>
</div> </div>
<Toggle text="Active" bind:value={active} /> <Toggle text="Active" bind:value={formValues.active}/>
<Toggle text="Set as default" bind:value={isDefault} /> <Toggle text="Set as default" bind:value={formValues.isDefault}/>
</ModalContent> </ModalContent>
<style> <style>
.form-row { .form-row {
display: grid; display: grid;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
align-items: center; align-items: center;
} }
</style> </style>

View File

@ -0,0 +1,8 @@
export const ConfigMap = {
OpenAI: {
baseUrl: "https://api.openai.com"
},
Anthropic: {
baseUrl: "https://api.together.xyz/v1"
},
}

View File

@ -8,57 +8,76 @@
Divider, Divider,
notifications, notifications,
Modal, Modal,
ModalContent, Tags,
Tag
} from "@budibase/bbui" } from "@budibase/bbui"
import { auth, admin } from "stores/portal" import { admin, licensing } from "stores/portal"
import { redirect } from "@roxi/routify"
import { API } from "api" import { API } from "api"
import AIConfigModal from "./ConfigModal.svelte" import AIConfigModal from "./ConfigModal.svelte"
import { onMount } from "svelte" import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
let diagnosticInfo = "" const ConfigTypes = {
let modal AI: "ai",
}
// async function fetchSystemDebugInfo() { let modal
// const diagnostics = await API.fetchSystemDebugInfo() let aiConfig
// diagnosticInfo = { let loading = false
// browser: {
// language: navigator.language || navigator.userLanguage, $: isCloud = $admin.cloud
// userAgent: navigator.userAgent, $: budibaseAIEnabled = $licensing.budibaseAIEnabled
// platform: navigator.platform, $: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
// vendor: navigator.vendor,
// }, async function fetchAIConfig() {
// server: diagnostics, loading = true
// } try {
// } // Fetch the configs for smtp
// const aiDoc = await API.getConfig(ConfigTypes.AI)
// const copyToClipboard = async () => { if (aiDoc._id) {
// await Helpers.copyToClipboard(JSON.stringify(diagnosticInfo, undefined, 2)) aiConfig = aiDoc
// notifications.success("Copied") }
// } loading = false
} catch (error) {
notifications.error("Error fetching AI config")
}
}
onMount(async () => { onMount(async () => {
// await fetchSystemDebugInfo() await fetchAIConfig()
}) })
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<Modal bind:this={modal}> <Modal bind:this={modal}>
<AIConfigModal /> <AIConfigModal />
</Modal> </Modal>
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">AI</Heading> <Heading size="M">AI</Heading>
{#if isCloud && !budibaseAIEnabled}
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
{/if}
<Body>Configure your AI settings within this section:</Body> <Body>Configure your AI settings within this section:</Body>
</Layout> </Layout>
<Divider /> <Divider />
<Layout noPadding> <Layout noPadding>
<div class="config-heading"> <div class="config-heading">
<Heading size="S">AI Configurations</Heading> <Heading size="S">AI Configurations</Heading>
<Button size="S" cta on:click={modal.show}>Add configuration</Button> {#if !isCloud && !customAIConfigsEnabled}
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
{:else if isCloud && !customAIConfigsEnabled}
<Tags>
<Tag icon="LockClosed">Enterprise</Tag>
</Tags>
{:else}
<Button size="S" cta on:click={modal.show}>Add configuration</Button>
{/if}
</div> </div>
<Body size="S">Use the following interface to select your preferred AI configuration.</Body> <Body size="S">Use the following interface to select your preferred AI configuration.</Body>
<Body size="S">Select your AI Model:</Body> <Body size="S">Select your AI Model:</Body>

View File

@ -22,6 +22,8 @@ export const createLicensingStore = () => {
backupsEnabled: false, backupsEnabled: false,
brandingEnabled: false, brandingEnabled: false,
scimEnabled: false, scimEnabled: false,
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
// the currently used quotas from the db // the currently used quotas from the db
quotaUsage: undefined, quotaUsage: undefined,
// derived quota metrics for percentages used // derived quota metrics for percentages used
@ -142,6 +144,14 @@ export const createLicensingStore = () => {
Constants.Features.VIEW_READONLY_COLUMNS Constants.Features.VIEW_READONLY_COLUMNS
) )
const budibaseAIEnabled = license.features.includes(
Constants.Features.BUDIBASE_AI
)
const customAIConfigsEnabled = license.features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
store.update(state => { store.update(state => {
return { return {
...state, ...state,
@ -153,6 +163,8 @@ export const createLicensingStore = () => {
groupsEnabled, groupsEnabled,
backupsEnabled, backupsEnabled,
brandingEnabled, brandingEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled, scimEnabled,
environmentVariablesEnabled, environmentVariablesEnabled,
auditLogsEnabled, auditLogsEnabled,

View File

@ -15,11 +15,12 @@ import {
} from "@budibase/types" } from "@budibase/types"
import env from "./../../../../environment" import env from "./../../../../environment"
export function getConfigParams(): DatabaseQueryOpts { export function getConfigParams(type?: ConfigType): DatabaseQueryOpts {
const configType = type || ""
return { return {
include_docs: true, include_docs: true,
startkey: `${DocumentType.CONFIG}${SEPARATOR}`, startkey: `${DocumentType.CONFIG}${SEPARATOR}${configType}`,
endkey: `${DocumentType.CONFIG}${SEPARATOR}${UNICODE_MAX}`, endkey: `${DocumentType.CONFIG}${SEPARATOR}${configType}${UNICODE_MAX}`,
} }
} }

View File

@ -111,6 +111,20 @@ export interface SCIMInnerConfig {
export interface SCIMConfig extends Config<SCIMInnerConfig> {} export interface SCIMConfig extends Config<SCIMInnerConfig> {}
export interface AIInnerConfig {
// TODO: should be enum
provider: string
isDefault: boolean
name: string
active: boolean
baseUrl: string
apiKey: string
// TODO: should be enum
defaultModel: string
}
export interface AIConfig extends Config<AIInnerConfig> {}
export const isSettingsConfig = (config: Config): config is SettingsConfig => export const isSettingsConfig = (config: Config): config is SettingsConfig =>
config.type === ConfigType.SETTINGS config.type === ConfigType.SETTINGS
@ -126,6 +140,9 @@ export const isOIDCConfig = (config: Config): config is OIDCConfig =>
export const isSCIMConfig = (config: Config): config is SCIMConfig => export const isSCIMConfig = (config: Config): config is SCIMConfig =>
config.type === ConfigType.SCIM config.type === ConfigType.SCIM
export const isAIConfig = (config: Config): config is AIConfig =>
config.type === ConfigType.AI
export enum ConfigType { export enum ConfigType {
SETTINGS = "settings", SETTINGS = "settings",
ACCOUNT = "account", ACCOUNT = "account",
@ -134,4 +151,5 @@ export enum ConfigType {
OIDC = "oidc", OIDC = "oidc",
OIDC_LOGOS = "logos_oidc", OIDC_LOGOS = "logos_oidc",
SCIM = "scim", SCIM = "scim",
AI = "ai"
} }

View File

@ -15,6 +15,8 @@ export enum Feature {
EXPANDED_PUBLIC_API = "expandedPublicApi", EXPANDED_PUBLIC_API = "expandedPublicApi",
VIEW_PERMISSIONS = "viewPermissions", VIEW_PERMISSIONS = "viewPermissions",
VIEW_READONLY_COLUMNS = "viewReadonlyColumns", VIEW_READONLY_COLUMNS = "viewReadonlyColumns",
BUDIBASE_AI = "budibaseAI",
AI_CUSTOM_CONFIGS = "customAIConfigs"
} }
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined } export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }

View File

@ -197,6 +197,12 @@ async function verifyOIDCConfig(config: OIDCConfigs) {
await verifySSOConfig(ConfigType.OIDC, config.configs[0]) await verifySSOConfig(ConfigType.OIDC, config.configs[0])
} }
async function verifyAIConfig(config: OIDCConfigs) {
// await verifySSOConfig(ConfigType.OIDC, config.configs[0])
// Shape should be `config_ai`
return true
}
export async function save(ctx: UserCtx<Config>) { export async function save(ctx: UserCtx<Config>) {
const body = ctx.request.body const body = ctx.request.body
const type = body.type const type = body.type
@ -224,6 +230,9 @@ export async function save(ctx: UserCtx<Config>) {
case ConfigType.OIDC: case ConfigType.OIDC:
await verifyOIDCConfig(config) await verifyOIDCConfig(config)
break break
case ConfigType.AI:
await verifyAIConfig(config)
break
} }
} catch (err: any) { } catch (err: any) {
ctx.throw(400, err) ctx.throw(400, err)
@ -314,6 +323,11 @@ export async function find(ctx: UserCtx) {
if (type === ConfigType.OIDC_LOGOS) { if (type === ConfigType.OIDC_LOGOS) {
enrichOIDCLogos(scopedConfig) enrichOIDCLogos(scopedConfig)
} }
if (type === ConfigType.AI) {
// TODO: strip the keys from the configs here
// TODO: do the licensing checks here and return the right things based on the license
}
ctx.body = scopedConfig ctx.body = scopedConfig
} else { } else {
// don't throw an error, there simply is nothing to return // don't throw an error, there simply is nothing to return