Merge branch 'master' into feat/ai-license-key-fix

This commit is contained in:
Peter Clement 2025-04-29 15:29:50 +01:00 committed by GitHub
commit eda4e2022f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 620 additions and 293 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
search = search.toLowerCase().replace(/_/g, " ")
} else {
selected = null
}
return commands
.filter(cmd => cmd.searchValue.includes(search))
.filter(cmd => {
// Handle searching
if (search && !cmd.searchValue.includes(search)) {
return false
}
// Handle commands that require an app
return inApp || !cmd.requiresApp
})
.map((cmd, idx) => ({
...cmd,
idx,
@ -264,7 +376,8 @@
<Icon size="M" name={command.icon} />
<strong>{command.type}:&nbsp;</strong>
<div class="name">
{command.name}
<!--eslint-disable-next-line svelte/no-at-html-tags-->
{@html command.name}
</div>
</div>
{/each}
@ -339,4 +452,10 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.name :global(code) {
font-size: 12px;
background: var(--background-alt);
padding: 4px;
border-radius: 4px;
}
</style>

View File

@ -3,7 +3,7 @@
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"
@ -43,17 +43,26 @@
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) {
console.error(e)
if (!(e instanceof Error)) {
notifications.error("Unable to generate code. Please try again later.")
return
}
if ("code" in e && e.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
notifications.error(
e instanceof Error
? `Unable to generate code: ${e.message}`
: "Unable to generate code. Please try again later."
"Monthly usage limit reached. We're exploring options to expand this soon. Questions? Contact support@budibase.com"
)
} else {
notifications.error(`Unable to generate code: ${e.message}`)
}
}
}
@ -78,6 +87,8 @@
function reset() {
suggestedCode = null
previousContents = null
promptText = ""
expanded = false
}
function calculateExpandedWidth() {
@ -103,7 +114,6 @@
placeholder="Generate with AI"
onSubmit={generateJs}
bind:expanded
on:collapse={rejectSuggestion}
readonly={!!suggestedCode}
{expandedOnly}
/>
@ -121,21 +131,11 @@
overflow: visible;
}
@keyframes border-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.floating-actions {
position: absolute;
display: flex;
gap: var(--spacing-s);
bottom: calc(100% + 5px);
left: 5px;
z-index: 2;
animation: fade-in 0.2s ease-out forwards;
}

View File

@ -9,7 +9,6 @@
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
import {
autocompletion,
closeBrackets,
@ -52,17 +51,12 @@
import type { Extension } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./"
import { themeStore } from "@/stores/portal"
import {
type EnrichedBinding,
FeatureFlag,
type EditorMode,
} from "@budibase/types"
import { featureFlags, themeStore } from "@/stores/portal"
import { type EnrichedBinding, type EditorMode } from "@budibase/types"
import { tooltips } from "@codemirror/view"
import type { BindingCompletion, CodeValidator } from "@/types"
import { validateHbsTemplate } from "./validator/hbs"
import { validateJsTemplate } from "./validator/js"
import { featureFlag } from "@/helpers"
import AIGen from "./AIGen.svelte"
export let label: string | undefined = undefined
@ -88,6 +82,7 @@
let isEditorInitialised = false
let queuedRefresh = false
let editorWidth: number | null = null
let isAIGeneratedContent = false
// Theming!
let currentTheme = $themeStore?.theme
@ -101,9 +96,7 @@
}
$: aiGenEnabled =
featureFlag.isEnabled(FeatureFlag.AI_JS_GENERATION) &&
mode.name === "javascript" &&
!readonly
$featureFlags.AI_JS_GENERATION && mode.name === "javascript" && !readonly
$: {
if (autofocus && isEditorInitialised) {
@ -429,6 +422,7 @@
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code },
})
isAIGeneratedContent = true
}
onMount(() => {
@ -462,7 +456,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 +474,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 +482,7 @@
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code || "" },
})
isAIGeneratedContent = false
}}
/>
{/if}
@ -691,4 +692,19 @@
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.code-editor.ai-generated :global(.cm-editor) {
background: var(--spectrum-global-color-blue-50) !important;
}
.code-editor.ai-generated :global(.cm-content) {
background: transparent !important;
}
.code-editor.ai-generated :global(.cm-line) {
background: #765ffe1a !important;
display: inline-block;
min-width: fit-content;
padding-right: 2px !important;
}
</style>

View File

@ -14,7 +14,6 @@
export const submit = onPromptSubmit
$: expanded = expandedOnly || expanded
const dispatch = createEventDispatcher()
let promptInput: HTMLInputElement
@ -28,7 +27,7 @@
$: aiEnabled = $auth?.user?.llm
$: creditsExceeded = $licensing.aiCreditsExceeded
$: disabled = !aiEnabled || creditsExceeded || readonly || promptLoading
$: disabled = !aiEnabled || creditsExceeded || readonly
$: animateBorder = !disabled && expanded
$: canSubmit = !readonly && !!value
@ -91,6 +90,7 @@
src={BBAI}
alt="AI"
class="ai-icon"
class:loading={promptLoading}
class:disabled={expanded && disabled}
on:click={e => {
e.stopPropagation()
@ -105,7 +105,7 @@
class="prompt-input"
{placeholder}
on:keydown={handleKeyPress}
{disabled}
disabled={disabled || promptLoading}
/>
{:else}
<span class="spectrum-ActionButton-label ai-gen-text">
@ -153,11 +153,11 @@
</Modal>
{:else}
<Icon
color={promptLoading
color={promptInput
? "#6E56FF"
: "var(--spectrum-global-color-gray-600)"}
size="S"
disabled={!canSubmit}
disabled={!canSubmit || promptLoading}
hoverable={!readonly}
hoverColor="#6E56FF"
name={promptLoading ? "StopCircle" : "PlayCircle"}
@ -170,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%;
@ -185,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%,
@ -204,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;
@ -268,10 +282,12 @@
.ai-icon {
width: 18px;
height: 18px;
margin-left: 4px;
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 {
@ -280,10 +296,12 @@
text-overflow: ellipsis;
transition: opacity 0.2s ease-out;
margin-right: var(--spacing-xs);
position: relative;
z-index: 2;
}
.prompt-input {
font-size: 14px;
font-size: inherit;
flex: 1;
border: none;
background: transparent;
@ -293,6 +311,8 @@
min-width: 0;
resize: none;
overflow: hidden;
position: relative;
z-index: 2;
}
.prompt-input::placeholder {
@ -303,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;
@ -328,4 +349,17 @@
filter: grayscale(1) brightness(1.5);
opacity: 0.5;
}
.ai-icon.loading {
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

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

View File

@ -14,7 +14,6 @@
Tabs,
Tab,
Heading,
Modal,
notifications,
TooltipPosition,
} from "@budibase/bbui"
@ -24,7 +23,6 @@
import { capitalise } from "@/helpers"
import { onMount, onDestroy } from "svelte"
import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte"
import CommandPalette from "@/components/commandPalette/CommandPalette.svelte"
import TourWrap from "@/components/portal/onboarding/TourWrap.svelte"
import TourPopover from "@/components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
@ -38,7 +36,6 @@
let promise = getPackage()
let hasSynced = false
let commandPaletteModal
let loaded = false
$: loaded && initTour()
@ -79,14 +76,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()
}
}
const initTour = async () => {
// Check if onboarding is enabled.
if (!$auth.user?.onboardedAt) {
@ -184,11 +173,6 @@
<PreviewOverlay />
{/if}
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette />
</Modal>
<EnterpriseBasicTrialModal />
<style>

View File

@ -16,8 +16,7 @@
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: CreateInternalTableModal
let externalDatasourceModal: CreateExternalDatasourceModal
@ -26,10 +25,7 @@
let externalDatasourceLoading = false
$: disabled = sampleDataLoading || externalDatasourceLoading
$: aiTableGenerationEnabled = featureFlag.isEnabled(
FeatureFlag.AI_TABLE_GENERATION
)
$: aiTableGenerationEnabled = $featureFlags.AI_TABLE_GENERATION
const createSampleData = async () => {
sampleDataLoading = true

View File

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

View File

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

View File

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

View File

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

View File

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

@ -1 +1 @@
Subproject commit 350e18c4aaf482d050143abd01076778a771b69a
Subproject commit cf45e608e393a7fc33d69e7a37b918aecac51100

View File

@ -13,11 +13,13 @@ export async function generateTables(
const tableGenerator = await ai.TableGeneration.init({
generateTablesDelegate: sdk.ai.helpers.generateTables,
getTablesDelegate: sdk.tables.getTables,
generateDataDelegate: sdk.ai.helpers.generateRows,
})
tableGenerator.withData(ctx.user._id || "")
const createdTables = await tableGenerator.generate(prompt)
const createdTables = await tableGenerator.generate(
prompt,
ctx.user._id || ""
)
ctx.body = {
createdTables,

View File

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

View File

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

View File

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

View File

@ -81,6 +81,7 @@ export async function updateRelatedFormula(
relatedRows[tableId].map(related =>
finaliseRow(relatedTable, related, {
updateFormula: false,
updateAIColumns: false,
})
)
)
@ -136,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
@ -155,9 +156,11 @@ export async function finaliseRow(
contextRows: [enrichedRow],
})
if (updateAIColumns) {
row = await processAIColumns(table, row, {
contextRows: [enrichedRow],
})
}
await db.put(row)
const retrieved = await db.tryGet<Row>(row._id)

View File

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

View File

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

View File

@ -30,6 +30,7 @@ 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"
@ -72,6 +73,7 @@ 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,

View File

@ -470,7 +470,9 @@ describe("BudibaseAI", () => {
) =>
mockChatGPTResponse(JSON.stringify(aiColumnGeneration), {
format: zodResponseFormat(
ai.aiColumnSchemas(generationStructure),
ai.aiColumnSchemas(
ai.aiTableResponseToTableSchema(generationStructure)
),
"key"
),
})
@ -684,7 +686,7 @@ describe("BudibaseAI", () => {
},
},
],
"Employees 2": [
Employees: [
{
"First Name": "Joshua",
"Last Name": "Lee",

View File

@ -1,11 +1,11 @@
import { FieldSchema, FieldType, Table } from "@budibase/types"
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, Table>
tables: Record<string, { _id: string; schema: TableSchema }>
) {
const createdData: Record<string, Record<string, string>> = {}
const toUpdateLinks: {
@ -13,6 +13,9 @@ export async function generateRows(
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> = {}
@ -26,7 +29,8 @@ export async function generateRows(
[FieldType.ATTACHMENTS, FieldType.ATTACHMENT_SINGLE].includes(f.type)
)
for (const entry of data[tableName]) {
rowPromises.push(
...data[tableName].map(async entry => {
await processAttachments(entry, attachmentColumns)
const tableId = table._id!
@ -62,10 +66,14 @@ export async function generateRows(
data: overridenLinks,
})
}
}
})
)
}
for (const data of toUpdateLinks) {
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>>(
@ -87,9 +95,11 @@ export async function generateRows(
...persistedRow,
...updatedLinks,
},
userId
userId,
{ updateAIColumns: false }
)
})
)
}
}
async function processAttachments(
entry: Record<string, any>,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,14 +32,12 @@ import {
OIDCConfigs,
OIDCLogosConfig,
PASSWORD_REPLACEMENT,
QuotaUsageType,
SaveConfigRequest,
SaveConfigResponse,
SettingsBrandingConfig,
SettingsInnerConfig,
SSOConfig,
SSOConfigType,
StaticQuotaName,
UploadConfigFileResponse,
UserCtx,
} from "@budibase/types"
@ -53,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) {
@ -92,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) {
@ -576,13 +567,6 @@ export async function destroy(ctx: UserCtx<void, DeleteConfigResponse>) {
try {
await db.remove(id, rev)
await cache.destroy(cache.CacheKey.CHECKLIST)
if (id === configs.generateConfigID(ConfigType.AI)) {
await pro.quotas.set(
StaticQuotaName.AI_CUSTOM_CONFIGS,
QuotaUsageType.STATIC,
0
)
}
ctx.body = { message: "Config deleted successfully" }
} catch (err: any) {
ctx.throw(err.status, err)

View File

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

View File

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