Merge branch 'master' into cheeks-fixes

This commit is contained in:
Andrew Kingston 2025-04-15 16:51:48 +01:00 committed by GitHub
commit eb0ea6e878
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 226 additions and 216 deletions

View File

@ -1,5 +1,16 @@
<script lang="ts"> <script lang="ts">
import { ActionButton, Icon, notifications } from "@budibase/bbui" import {
ActionButton,
Icon,
notifications,
Button,
Modal,
ModalContent,
Body,
Link,
} from "@budibase/bbui"
import { auth, admin, licensing } from "@/stores/portal"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { API } from "@/api" import { API } from "@/api"
import type { EnrichedBinding } from "@budibase/types" import type { EnrichedBinding } from "@budibase/types"
@ -8,6 +19,8 @@
export let bindings: EnrichedBinding[] = [] export let bindings: EnrichedBinding[] = []
export let value: string | null = "" export let value: string | null = ""
export let expandedOnly: boolean = false
export let parentWidth: number | null = null export let parentWidth: number | null = null
export const dispatch = createEventDispatcher<{ export const dispatch = createEventDispatcher<{
update: { code: string } update: { code: string }
@ -15,28 +28,27 @@
reject: { code: string | null } reject: { code: string | null }
}>() }>()
let buttonContainer: HTMLElement let promptInput: HTMLInputElement
let promptInput: HTMLTextAreaElement
let buttonElement: HTMLButtonElement let buttonElement: HTMLButtonElement
let promptLoading = false let promptLoading = false
let suggestedCode: string | null = null let suggestedCode: string | null = null
let previousContents: string | null = null let previousContents: string | null = null
let expanded = false let expanded = false
let containerWidth = "auto" let containerWidth = "auto"
let containerHeight = "40px"
let promptText = "" let promptText = ""
let animateBorder = false let animateBorder = false
let switchOnAIModal: Modal
let addCreditsModal: Modal
function adjustContainerHeight() { $: accountPortalAccess = $auth?.user?.accountPortalAccess
if (promptInput && buttonElement) { $: accountPortal = $admin.accountPortalUrl
promptInput.style.height = "0px" $: aiEnabled = $auth?.user?.llm
const newHeight = Math.min(promptInput.scrollHeight, 100) $: expanded = expandedOnly ? true : expanded
promptInput.style.height = `${newHeight}px` $: creditsExceeded = $licensing.aiCreditsExceeded
containerHeight = `${Math.max(40, newHeight + 20)}px` $: disabled = suggestedCode !== null || !aiEnabled || creditsExceeded
$: if (expandedOnly) {
containerWidth = calculateExpandedWidth()
} }
}
$: if (promptInput && promptText) adjustContainerHeight()
async function generateJs(prompt: string) { async function generateJs(prompt: string) {
if (!prompt.trim()) return if (!prompt.trim()) return
@ -85,22 +97,23 @@
function resetExpand() { function resetExpand() {
expanded = false expanded = false
containerWidth = "auto" containerWidth = "auto"
containerHeight = "40px"
promptText = "" promptText = ""
suggestedCode = null suggestedCode = null
previousContents = null previousContents = null
animateBorder = false animateBorder = false
} }
function calculateExpandedWidth() {
return parentWidth
? `${Math.min(Math.max(parentWidth * 0.8, 300), 600)}px`
: "300px"
}
function toggleExpand() { function toggleExpand() {
if (!expanded) { if (!expanded) {
expanded = true expanded = true
animateBorder = true animateBorder = true
// Calculate width based on size of CodeEditor parent containerWidth = calculateExpandedWidth()
containerWidth = parentWidth
? `${Math.min(Math.max(parentWidth * 0.8, 300), 600)}px`
: "300px"
containerHeight = "40px"
setTimeout(() => { setTimeout(() => {
promptInput?.focus() promptInput?.focus()
}, 250) }, 250)
@ -125,11 +138,9 @@
} }
</script> </script>
<div <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
class="ai-gen-container" <!-- svelte-ignore a11y-click-events-have-key-events -->
style="--container-width: {containerWidth}; --container-height: {containerHeight}" <div class="ai-gen-container" style="--container-width: {containerWidth}">
bind:this={buttonContainer}
>
{#if suggestedCode !== null} {#if suggestedCode !== null}
<div class="floating-actions"> <div class="floating-actions">
<ActionButton size="S" icon="CheckmarkCircle" on:click={acceptSuggestion}> <ActionButton size="S" icon="CheckmarkCircle" on:click={acceptSuggestion}>
@ -140,7 +151,6 @@
</ActionButton> </ActionButton>
</div> </div>
{/if} {/if}
<button <button
bind:this={buttonElement} bind:this={buttonElement}
class="spectrum-ActionButton fade" class="spectrum-ActionButton fade"
@ -149,18 +159,28 @@
on:click={!expanded ? toggleExpand : undefined} on:click={!expanded ? toggleExpand : undefined}
> >
<div class="button-content-wrapper"> <div class="button-content-wrapper">
<img src={BBAI} alt="AI" class="ai-icon" /> <img
src={BBAI}
alt="AI"
class="ai-icon"
class:disabled={expanded && disabled}
on:click={!expandedOnly
? e => {
e.stopPropagation()
toggleExpand()
}
: undefined}
/>
{#if expanded} {#if expanded}
<textarea <input
type="text"
bind:this={promptInput} bind:this={promptInput}
bind:value={promptText} bind:value={promptText}
class="prompt-input" class="prompt-input"
placeholder="Generate Javascript..." placeholder="Generate Javascript..."
on:keydown={handleKeyPress} on:keydown={handleKeyPress}
on:input={adjustContainerHeight} {disabled}
disabled={suggestedCode !== null}
readonly={suggestedCode !== null} readonly={suggestedCode !== null}
rows="1"
/> />
{:else} {:else}
<span class="spectrum-ActionButton-label ai-gen-text"> <span class="spectrum-ActionButton-label ai-gen-text">
@ -168,9 +188,46 @@
</span> </span>
{/if} {/if}
</div> </div>
{#if expanded} {#if expanded}
<div class="action-buttons"> <div class="action-buttons">
{#if !aiEnabled}
<Button cta size="S" on:click={() => switchOnAIModal.show()}>
Switch on AI
</Button>
<Modal bind:this={switchOnAIModal}>
<ModalContent title="Switch on AI" showConfirmButton={false}>
<div class="enable-ai">
<p>To enable BB AI:</p>
<ul>
<li>
Add your Budibase license key:
<Link href={accountPortal}>Budibase account portal</Link>
</li>
<li>
Go to the portal settings page, click AI and switch on BB AI
</li>
</ul>
</div>
</ModalContent>
</Modal>
{:else if creditsExceeded}
<Button cta size="S" on:click={() => addCreditsModal.show()}>
Add AI credits
</Button>
<Modal bind:this={addCreditsModal}>
<ModalContent title="Add AI credits" showConfirmButton={false}>
<Body size="S">
{#if accountPortalAccess}
<Link href={"https://budibase.com/contact/"}
>Contact sales</Link
> to unlock additional BB AI credits
{:else}
Contact your account holder to unlock additional BB AI credits
{/if}
</Body>
</ModalContent>
</Modal>
{:else}
<Icon <Icon
color={promptLoading color={promptLoading
? "#6E56FF" ? "#6E56FF"
@ -181,16 +238,7 @@
name={promptLoading ? "StopCircle" : "PlayCircle"} name={promptLoading ? "StopCircle" : "PlayCircle"}
on:click={() => generateJs(promptText)} on:click={() => generateJs(promptText)}
/> />
<Icon {/if}
hoverable
size="S"
name="Close"
hoverColor="#6E56FF"
on:click={e => {
e.stopPropagation()
if (!suggestedCode && !promptLoading) toggleExpand()
}}
/>
</div> </div>
{/if} {/if}
</button> </button>
@ -198,13 +246,12 @@
<style> <style>
.ai-gen-container { .ai-gen-container {
height: 40px;
--container-width: auto; --container-width: auto;
--container-height: 40px;
position: absolute; position: absolute;
right: 10px; right: 10px;
bottom: 10px; bottom: 10px;
width: var(--container-width); width: var(--container-width);
height: var(--container-height);
display: flex; display: flex;
overflow: visible; overflow: visible;
} }
@ -238,7 +285,7 @@
background: linear-gradient( background: linear-gradient(
125deg, 125deg,
transparent -10%, transparent -10%,
#6e56ff 5%, #6e56ff 2%,
#9f8fff 15%, #9f8fff 15%,
#9f8fff 25%, #9f8fff 25%,
transparent 35%, transparent 35%,
@ -320,6 +367,7 @@
height: 18px; height: 18px;
margin-right: 8px; margin-right: 8px;
flex-shrink: 0; flex-shrink: 0;
cursor: var(--ai-icon-cursor, pointer);
} }
.ai-gen-text { .ai-gen-text {
@ -335,18 +383,16 @@
border: none; border: none;
background: transparent; background: transparent;
outline: none; outline: none;
font-size: var(--font-size-s);
font-family: var(--font-sans); font-family: var(--font-sans);
color: var(--spectrum-alias-text-color); color: var(--spectrum-alias-text-color);
min-width: 0; min-width: 0;
resize: none; resize: none;
overflow: hidden; overflow: hidden;
line-height: 1.2;
min-height: 10px !important;
} }
.prompt-input::placeholder { .prompt-input::placeholder {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
font-family: var(--font-sans);
} }
.action-buttons { .action-buttons {
@ -354,6 +400,7 @@
gap: var(--spacing-s); gap: var(--spacing-s);
z-index: 4; z-index: 4;
flex-shrink: 0; flex-shrink: 0;
margin-right: var(--spacing-s);
} }
.button-content-wrapper { .button-content-wrapper {
@ -366,4 +413,15 @@
min-width: 0; min-width: 0;
margin-right: var(--spacing-s); margin-right: var(--spacing-s);
} }
.prompt-input:disabled,
.prompt-input[readonly] {
color: var(--spectrum-global-color-gray-500);
cursor: not-allowed;
}
.ai-icon.disabled {
filter: grayscale(1) brightness(1.5);
opacity: 0.5;
}
</style> </style>

View File

@ -469,6 +469,7 @@
{#if aiGenEnabled} {#if aiGenEnabled}
<AIGen <AIGen
expandedOnly={true}
{bindings} {bindings}
{value} {value}
parentWidth={editorWidth} parentWidth={editorWidth}

View File

@ -56,7 +56,9 @@ interface LicensingState {
// user limits // user limits
userCount?: number userCount?: number
userLimit?: number userLimit?: number
aiCreditsLimit?: number
userLimitReached: boolean userLimitReached: boolean
aiCreditsExceeded: boolean
errUserLimit: boolean errUserLimit: boolean
} }
@ -102,6 +104,8 @@ class LicensingStore extends BudiStore<LicensingState> {
userLimit: undefined, userLimit: undefined,
userLimitReached: false, userLimitReached: false,
errUserLimit: false, errUserLimit: false,
// AI Limits
aiCreditsExceeded: false,
}) })
} }
@ -119,6 +123,16 @@ class LicensingStore extends BudiStore<LicensingState> {
return userCount > userLimit return userCount > userLimit
} }
aiCreditsExceeded(
aiCredits: number,
aiCreditsLimit = get(this.store).aiCreditsLimit
) {
if (aiCreditsLimit === UNLIMITED || aiCreditsLimit === undefined) {
return false
}
return aiCredits > aiCreditsLimit
}
async isCloud() { async isCloud() {
let adminStore = get(admin) let adminStore = get(admin)
if (!adminStore.loaded) { if (!adminStore.loaded) {
@ -291,9 +305,15 @@ class LicensingStore extends BudiStore<LicensingState> {
const userQuota = license.quotas.usage.static.users const userQuota = license.quotas.usage.static.users
const userLimit = userQuota.value const userLimit = userQuota.value
const aiCreditsQuota = license.quotas.usage.monthly.budibaseAICredits
const aiCreditsLimit = aiCreditsQuota.value
const userCount = usage.usageQuota.users const userCount = usage.usageQuota.users
const userLimitReached = this.usersLimitReached(userCount, userLimit) const userLimitReached = this.usersLimitReached(userCount, userLimit)
const userLimitExceeded = this.usersLimitExceeded(userCount, userLimit) const userLimitExceeded = this.usersLimitExceeded(userCount, userLimit)
const aiCreditsExceeded = this.aiCreditsExceeded(
usage.monthly.current.budibaseAICredits,
aiCreditsLimit
)
const isCloudAccount = await this.isCloud() const isCloudAccount = await this.isCloud()
const errUserLimit = const errUserLimit =
isCloudAccount && isCloudAccount &&
@ -315,6 +335,8 @@ class LicensingStore extends BudiStore<LicensingState> {
userLimit, userLimit,
userLimitReached, userLimitReached,
errUserLimit, errUserLimit,
aiCreditsLimit,
aiCreditsExceeded,
} }
}) })
} }

@ -1 +1 @@
Subproject commit 8bcb90edac3d44c48eaecf526cedc82418035438 Subproject commit f27612865cd5f689b75b8f4e148293dff3b77bc4

View File

@ -13,8 +13,6 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { mocks } from "@budibase/backend-core/tests" import { mocks } from "@budibase/backend-core/tests"
import { MockLLMResponseFn } from "../../../tests/utilities/mocks/ai"
import { mockAnthropicResponse } from "../../../tests/utilities/mocks/ai/anthropic"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
function dedent(str: string) { function dedent(str: string) {
@ -30,7 +28,6 @@ type SetupFn = (
interface TestSetup { interface TestSetup {
name: string name: string
setup: SetupFn setup: SetupFn
mockLLMResponse: MockLLMResponseFn
} }
function budibaseAI(): SetupFn { function budibaseAI(): SetupFn {
@ -89,25 +86,14 @@ const allProviders: TestSetup[] = [
OPENAI_API_KEY: "test-key", OPENAI_API_KEY: "test-key",
}) })
}, },
mockLLMResponse: mockChatGPTResponse,
}, },
{ {
name: "OpenAI API key with custom config", name: "OpenAI API key with custom config",
setup: customAIConfig({ provider: "OpenAI", defaultModel: "gpt-4o-mini" }), setup: customAIConfig({ provider: "OpenAI", defaultModel: "gpt-4o-mini" }),
mockLLMResponse: mockChatGPTResponse,
},
{
name: "Anthropic API key with custom config",
setup: customAIConfig({
provider: "Anthropic",
defaultModel: "claude-3-5-sonnet-20240620",
}),
mockLLMResponse: mockAnthropicResponse,
}, },
{ {
name: "BudibaseAI", name: "BudibaseAI",
setup: budibaseAI(), setup: budibaseAI(),
mockLLMResponse: mockChatGPTResponse,
}, },
] ]
@ -126,9 +112,7 @@ describe("AI", () => {
nock.cleanAll() nock.cleanAll()
}) })
describe.each(allProviders)( describe.each(allProviders)("provider: $name", ({ setup }: TestSetup) => {
"provider: $name",
({ setup, mockLLMResponse }: TestSetup) => {
let cleanup: () => Promise<void> | void let cleanup: () => Promise<void> | void
beforeAll(async () => { beforeAll(async () => {
cleanup = await setup(config) cleanup = await setup(config)
@ -154,14 +138,14 @@ describe("AI", () => {
}) })
it("handles correct plain code response", async () => { it("handles correct plain code response", async () => {
mockLLMResponse(`return 42`) mockChatGPTResponse(`return 42`)
const { code } = await config.api.ai.generateJs({ prompt: "test" }) const { code } = await config.api.ai.generateJs({ prompt: "test" })
expect(code).toBe("return 42") expect(code).toBe("return 42")
}) })
it("handles correct markdown code response", async () => { it("handles correct markdown code response", async () => {
mockLLMResponse( mockChatGPTResponse(
dedent(` dedent(`
\`\`\`js \`\`\`js
return 42 return 42
@ -174,7 +158,7 @@ describe("AI", () => {
}) })
it("handles multiple markdown code blocks returned", async () => { it("handles multiple markdown code blocks returned", async () => {
mockLLMResponse( mockChatGPTResponse(
dedent(` dedent(`
This: This:
@ -196,13 +180,13 @@ describe("AI", () => {
// TODO: handle when this happens // TODO: handle when this happens
it.skip("handles no code response", async () => { it.skip("handles no code response", async () => {
mockLLMResponse("I'm sorry, you're quite right, etc.") mockChatGPTResponse("I'm sorry, you're quite right, etc.")
const { code } = await config.api.ai.generateJs({ prompt: "test" }) const { code } = await config.api.ai.generateJs({ prompt: "test" })
expect(code).toBe("") expect(code).toBe("")
}) })
it("handles LLM errors", async () => { it("handles LLM errors", async () => {
mockLLMResponse(() => { mockChatGPTResponse(() => {
throw new Error("LLM error") throw new Error("LLM error")
}) })
await config.api.ai.generateJs({ prompt: "test" }, { status: 500 }) await config.api.ai.generateJs({ prompt: "test" }, { status: 500 })
@ -211,7 +195,7 @@ describe("AI", () => {
describe("POST /api/ai/cron", () => { describe("POST /api/ai/cron", () => {
it("handles correct cron response", async () => { it("handles correct cron response", async () => {
mockLLMResponse("0 0 * * *") mockChatGPTResponse("0 0 * * *")
const { message } = await config.api.ai.generateCron({ const { message } = await config.api.ai.generateCron({
prompt: "test", prompt: "test",
@ -220,7 +204,7 @@ describe("AI", () => {
}) })
it("handles expected LLM error", async () => { it("handles expected LLM error", async () => {
mockLLMResponse("Error generating cron: skill issue") mockChatGPTResponse("Error generating cron: skill issue")
await config.api.ai.generateCron( await config.api.ai.generateCron(
{ {
@ -231,7 +215,7 @@ describe("AI", () => {
}) })
it("handles unexpected LLM error", async () => { it("handles unexpected LLM error", async () => {
mockLLMResponse(() => { mockChatGPTResponse(() => {
throw new Error("LLM error") throw new Error("LLM error")
}) })
@ -243,8 +227,7 @@ describe("AI", () => {
) )
}) })
}) })
} })
)
}) })
describe("BudibaseAI", () => { describe("BudibaseAI", () => {

View File

@ -1,48 +0,0 @@
import AnthropicClient from "@anthropic-ai/sdk"
import nock from "nock"
import { MockLLMResponseFn, MockLLMResponseOpts } from "."
let chatID = 1
const SPACE_REGEX = /\s+/g
export const mockAnthropicResponse: MockLLMResponseFn = (
answer: string | ((prompt: string) => string),
opts?: MockLLMResponseOpts
) => {
return nock(opts?.host || "https://api.anthropic.com")
.post("/v1/messages")
.reply((uri: string, body: nock.Body) => {
const req = body as AnthropicClient.MessageCreateParamsNonStreaming
const prompt = req.messages[0].content
if (typeof prompt !== "string") {
throw new Error("Anthropic mock only supports string prompts")
}
let content
if (typeof answer === "function") {
try {
content = answer(prompt)
} catch (e) {
return [500, "Internal Server Error"]
}
} else {
content = answer
}
const resp: AnthropicClient.Messages.Message = {
id: `${chatID++}`,
type: "message",
role: "assistant",
model: req.model,
stop_reason: "end_turn",
usage: {
input_tokens: prompt.split(SPACE_REGEX).length,
output_tokens: content.split(SPACE_REGEX).length,
},
stop_sequence: null,
content: [{ type: "text", text: content }],
}
return [200, resp]
})
.persist()
}

View File

@ -111,13 +111,7 @@ export interface SCIMInnerConfig {
export interface SCIMConfig extends Config<SCIMInnerConfig> {} export interface SCIMConfig extends Config<SCIMInnerConfig> {}
export type AIProvider = export type AIProvider = "OpenAI" | "AzureOpenAI" | "BudibaseAI"
| "OpenAI"
| "Anthropic"
| "AzureOpenAI"
| "TogetherAI"
| "Custom"
| "BudibaseAI"
export interface ProviderConfig { export interface ProviderConfig {
provider: AIProvider provider: AIProvider