budibase/packages/builder/src/components/common/CodeEditor/AIGen.svelte

428 lines
10 KiB
Svelte

<script lang="ts">
import {
ActionButton,
Icon,
notifications,
Button,
Modal,
ModalContent,
Body,
Link,
} from "@budibase/bbui"
import { auth, admin, licensing } from "@/stores/portal"
import { createEventDispatcher } from "svelte"
import { API } from "@/api"
import type { EnrichedBinding } from "@budibase/types"
import BBAI from "assets/bb-ai.svg"
import analytics, { Events } from "@/analytics"
export let bindings: EnrichedBinding[] = []
export let value: string | null = ""
export let expandedOnly: boolean = false
export let parentWidth: number | null = null
export const dispatch = createEventDispatcher<{
update: { code: string }
accept: void
reject: { code: string | null }
}>()
let promptInput: HTMLInputElement
let buttonElement: HTMLButtonElement
let promptLoading = false
let suggestedCode: string | null = null
let previousContents: string | null = null
let expanded = false
let containerWidth = "auto"
let promptText = ""
let animateBorder = false
let switchOnAIModal: Modal
let addCreditsModal: Modal
$: accountPortalAccess = $auth?.user?.accountPortalAccess
$: accountPortal = $admin.accountPortalUrl
$: aiEnabled = $auth?.user?.llm
$: expanded = expandedOnly ? true : expanded
$: creditsExceeded = $licensing.aiCreditsExceeded
$: disabled = suggestedCode !== null || !aiEnabled || creditsExceeded
$: if (expandedOnly) {
containerWidth = calculateExpandedWidth()
}
async function generateJs(prompt: string) {
if (!prompt.trim()) return
previousContents = value
promptLoading = true
animateBorder = true
try {
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.")
}
suggestedCode = code
dispatch("update", { code })
} catch (e) {
console.error(e)
notifications.error(
e instanceof Error
? `Unable to generate code: ${e.message}`
: "Unable to generate code. Please try again later."
)
} finally {
promptLoading = false
}
}
function acceptSuggestion() {
analytics.captureEvent(Events.AI_JS_ACCEPTED, {
code: suggestedCode,
prompt: promptText,
})
dispatch("accept")
resetExpand()
}
function rejectSuggestion() {
analytics.captureEvent(Events.AI_JS_REJECTED, {
code: suggestedCode,
prompt: promptText,
})
dispatch("reject", { code: previousContents })
resetExpand()
}
function resetExpand() {
expanded = false
containerWidth = "auto"
promptText = ""
suggestedCode = null
previousContents = null
animateBorder = false
}
function calculateExpandedWidth() {
return parentWidth
? `${Math.min(Math.max(parentWidth * 0.8, 300), 600)}px`
: "300px"
}
function toggleExpand() {
if (!expanded) {
expanded = true
animateBorder = true
containerWidth = calculateExpandedWidth()
setTimeout(() => {
promptInput?.focus()
}, 250)
} else {
resetExpand()
}
}
function handleKeyPress(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
generateJs(promptText)
} else if (event.key === "Escape") {
if (!suggestedCode) resetExpand()
else {
expanded = false
containerWidth = "auto"
}
} else {
event.stopPropagation()
}
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="ai-gen-container" style="--container-width: {containerWidth}">
{#if suggestedCode !== null}
<div class="floating-actions">
<ActionButton size="S" icon="CheckmarkCircle" on:click={acceptSuggestion}>
Accept
</ActionButton>
<ActionButton size="S" icon="Delete" on:click={rejectSuggestion}>
Reject
</ActionButton>
</div>
{/if}
<button
bind:this={buttonElement}
class="spectrum-ActionButton fade"
class:expanded
class:animate-border={animateBorder}
on:click={!expanded ? toggleExpand : undefined}
>
<div class="button-content-wrapper">
<img
src={BBAI}
alt="AI"
class="ai-icon"
class:disabled={expanded && disabled}
on:click={!expandedOnly
? e => {
e.stopPropagation()
toggleExpand()
}
: undefined}
/>
{#if expanded}
<input
type="text"
bind:this={promptInput}
bind:value={promptText}
class="prompt-input"
placeholder="Generate Javascript..."
on:keydown={handleKeyPress}
{disabled}
readonly={suggestedCode !== null}
/>
{:else}
<span class="spectrum-ActionButton-label ai-gen-text">
Generate with AI
</span>
{/if}
</div>
{#if expanded}
<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
color={promptLoading
? "#6E56FF"
: "var(--spectrum-global-color-gray-600)"}
size="S"
hoverable
hoverColor="#6E56FF"
name={promptLoading ? "StopCircle" : "PlayCircle"}
on:click={() => generateJs(promptText)}
/>
{/if}
</div>
{/if}
</button>
</div>
<style>
.ai-gen-container {
height: 40px;
--container-width: auto;
position: absolute;
right: 10px;
bottom: 10px;
width: var(--container-width);
display: flex;
overflow: visible;
}
.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%;
height: 100%;
overflow: hidden;
cursor: pointer;
background-color: var(--spectrum-global-color-gray-75);
}
.spectrum-ActionButton::before {
content: "";
position: absolute;
top: -1px;
left: -1px;
width: calc(100% + 2px);
height: calc(100% + 2px);
border-radius: inherit;
background: linear-gradient(
125deg,
transparent -10%,
#6e56ff 2%,
#9f8fff 15%,
#9f8fff 25%,
transparent 35%,
transparent 110%
);
pointer-events: none;
z-index: 0;
}
.spectrum-ActionButton:not(.animate-border)::before {
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;
}
.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;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.spectrum-ActionButton:hover {
cursor: pointer;
background-color: var(--spectrum-global-color-gray-75);
}
.spectrum-ActionButton.expanded {
border-radius: 30px;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.2s ease-out;
}
.fade {
transition: all 2s ease-in;
}
.ai-icon {
width: 18px;
height: 18px;
margin-right: 8px;
flex-shrink: 0;
cursor: var(--ai-icon-cursor, pointer);
}
.ai-gen-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.2s ease-out;
margin-right: var(--spacing-xs);
}
.prompt-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-family: var(--font-sans);
color: var(--spectrum-alias-text-color);
min-width: 0;
resize: none;
overflow: hidden;
}
.prompt-input::placeholder {
color: var(--spectrum-global-color-gray-600);
font-family: var(--font-sans);
}
.action-buttons {
display: flex;
gap: var(--spacing-s);
z-index: 4;
flex-shrink: 0;
margin-right: var(--spacing-s);
}
.button-content-wrapper {
position: relative;
z-index: 1;
display: flex;
align-items: center;
overflow: hidden;
flex-grow: 1;
min-width: 0;
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>