428 lines
10 KiB
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>
|