Updated UX for the AI JS work

This commit is contained in:
Peter Clement 2025-04-10 08:41:14 +01:00
parent 44ef7168b9
commit c4f8a608a4
4 changed files with 355 additions and 150 deletions

View File

@ -0,0 +1,24 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.360872 11.5905L4.90819 16.139C5.10754 16.3384 5.43058 16.3384 5.62993 16.139L7.61005 14.1583C7.78825 13.9801 7.87247 13.7282 7.83748 13.4783L7.30573 9.71929C7.26667 9.44582 7.05186 9.23095 6.77886 9.19228L3.02083 8.6604C2.77143 8.62499 2.5196 8.70923 2.34099 8.88788L0.360872 10.8685C0.161518 11.0679 0.161518 11.3911 0.360872 11.5905Z" fill="url(#paint0_linear_1216_26668)"/>
<path d="M11.0826 16.139L15.6299 11.5905C15.8292 11.3911 15.8292 11.068 15.6299 10.8686L13.6498 8.88794C13.4716 8.70969 13.2197 8.62545 12.9699 8.66045L9.21188 9.19234C8.93847 9.23141 8.72366 9.44628 8.68501 9.71934L8.15326 13.4784C8.11787 13.7278 8.20208 13.9797 8.38069 14.1584L10.3608 16.139C10.5602 16.3384 10.8832 16.3384 11.0826 16.139Z" fill="url(#paint1_linear_1216_26668)"/>
<path d="M15.6391 5.40954L11.0918 0.861023C10.8925 0.661616 10.5694 0.661614 10.3701 0.861021L8.38995 2.84166C8.21175 3.01991 8.12753 3.27181 8.16252 3.52168L8.69427 7.28071C8.73333 7.55418 8.94814 7.76905 9.22114 7.80772L12.9792 8.3396C13.2286 8.37501 13.4804 8.29077 13.659 8.11212L15.6391 6.13148C15.8385 5.93207 15.8385 5.60895 15.6391 5.40954Z" fill="url(#paint2_linear_1216_26668)"/>
<path d="M4.91745 0.860967L0.370132 5.40948C0.170778 5.60889 0.170776 5.93201 0.370131 6.13142L2.35025 8.11206C2.52845 8.29031 2.78028 8.37455 3.03009 8.33955L6.78812 7.80766C7.06152 7.76859 7.27634 7.55372 7.31499 7.28066L7.84674 3.52163C7.88213 3.27217 7.79792 3.02026 7.61931 2.84161L5.63919 0.860967C5.43984 0.66156 5.1168 0.66156 4.91745 0.860967Z" fill="url(#paint3_linear_1216_26668)"/>
<defs>
<linearGradient id="paint0_linear_1216_26668" x1="4.94623" y1="15.6582" x2="7.43064" y2="9.91942" gradientUnits="userSpaceOnUse">
<stop stop-color="#6E56FF"/>
<stop offset="1" stop-color="#9F8FFF"/>
</linearGradient>
<linearGradient id="paint1_linear_1216_26668" x1="15.1492" y1="11.5525" x2="9.411" y2="9.06961" gradientUnits="userSpaceOnUse">
<stop stop-color="#6E56FF"/>
<stop offset="1" stop-color="#9F8FFF"/>
</linearGradient>
<linearGradient id="paint2_linear_1216_26668" x1="11.0538" y1="1.34184" x2="8.56936" y2="7.08058" gradientUnits="userSpaceOnUse">
<stop stop-color="#6E56FF"/>
<stop offset="1" stop-color="#9F8FFF"/>
</linearGradient>
<linearGradient id="paint3_linear_1216_26668" x1="0.850819" y1="5.44754" x2="6.589" y2="7.93039" gradientUnits="userSpaceOnUse">
<stop stop-color="#6E56FF"/>
<stop offset="1" stop-color="#9F8FFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,285 @@
<script lang="ts">
import { ActionButton, Icon, notifications } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { API } from "@/api"
import type { EnrichedBinding } from "@budibase/types"
import BBAI from "assets/bb-ai.svg"
export let bindings: EnrichedBinding[] = []
export let value: string | null = ""
export let parentWidth: number | null = null
const dispatch = createEventDispatcher()
let buttonContainer: HTMLElement
let promptInput: HTMLTextAreaElement
let buttonElement: HTMLButtonElement
let promptLoading = false
let suggestedCode: string | null = null
let previousContents: string | null = null
let expanded = false
let containerWidth = "auto"
let containerHeight = "35px"
let promptText = ""
function adjustContainerHeight() {
if (promptInput && buttonElement) {
promptInput.style.height = "0px"
const newHeight = Math.min(promptInput.scrollHeight, 100)
promptInput.style.height = `${newHeight}px`
containerHeight = `${Math.max(40, newHeight + 20)}px`
}
}
$: if (promptInput && promptText) adjustContainerHeight()
async function generateJs(prompt: string) {
if (!prompt.trim()) return
previousContents = value
promptLoading = 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() {
dispatch("accept")
resetExpand()
}
function rejectSuggestion() {
dispatch("reject", { code: previousContents })
resetExpand()
}
function resetExpand() {
expanded = false
containerWidth = "auto"
containerHeight = "40px"
promptText = ""
suggestedCode = null
previousContents = null
}
function toggleExpand() {
if (!expanded) {
expanded = true
// Dynamic width based on parent size, with minimum and maximum constraints
containerWidth = parentWidth
? `${Math.min(Math.max(parentWidth * 0.8, 300), 600)}px`
: "300px"
containerHeight = "40px"
setTimeout(() => {
promptInput?.focus()
adjustContainerHeight()
}, 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>
<div
class="ai-gen-container"
style="--container-width: {containerWidth}; --container-height: {containerHeight}"
bind:this={buttonContainer}
>
{#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
on:click={!expanded ? toggleExpand : undefined}
>
<img src={BBAI} alt="AI" class="ai-icon" />
{#if expanded}
<textarea
bind:this={promptInput}
bind:value={promptText}
class="prompt-input"
placeholder="Generate Javascript..."
on:keydown={handleKeyPress}
on:input={adjustContainerHeight}
disabled={suggestedCode !== null}
readonly={suggestedCode !== null}
rows="1"
/>
<div class="action-buttons">
<Icon
color={promptLoading
? "#6E56FF"
: "var(--spectrum-global-color-gray-600)"}
size="S"
hoverable
name={promptLoading ? "StopCircle" : "PlayCircle"}
on:click={() => generateJs(promptText)}
/>
<Icon
hoverable
size="S"
name="Close"
on:click={e => {
e.stopPropagation()
if (!suggestedCode && !promptLoading) toggleExpand()
}}
/>
</div>
{:else}
<span class="spectrum-ActionButton-label ai-gen-text">
Generate with AI
</span>
{/if}
</button>
</div>
<style>
.ai-gen-container {
--container-width: auto;
--container-height: 40px;
position: absolute;
right: 10px;
bottom: 10px;
width: var(--container-width);
height: var(--container-height);
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: flex-start;
justify-content: flex-end;
overflow: visible;
z-index: 1;
}
.floating-actions {
position: absolute;
display: flex;
gap: var(--spacing-s);
bottom: calc(100% + 5px);
left: 0;
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 {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
padding: var(--spacing-s);
border: 1px solid var(--spectrum-alias-border-color);
border-radius: var(--spectrum-alias-border-radius-regular);
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
height: 100%;
}
.spectrum-ActionButton:hover {
cursor: pointer;
background-color: var(--spectrum-global-color-gray-75);
}
.spectrum-ActionButton.expanded {
border-radius: var(--spectrum-alias-border-radius-regular);
}
.fade {
transition: all 2s ease-in;
}
.ai-icon {
width: 18px;
height: 18px;
margin-right: 8px;
flex-shrink: 0;
}
.ai-gen-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.2s ease-out;
}
.prompt-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: var(--font-size-s);
font-family: var(--font-sans);
color: var(--spectrum-alias-text-color);
padding: 0;
margin: 0;
min-width: 0;
resize: none;
overflow: hidden;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.4;
min-height: 10px !important;
}
.prompt-input::placeholder {
color: var(--spectrum-global-color-gray-600);
font-style: italic;
}
.action-buttons {
display: flex;
gap: var(--spacing-l);
padding-right: var(--spacing-s);
z-index: 4;
}
</style>

View File

@ -6,13 +6,7 @@
</script>
<script lang="ts">
import {
Button,
Label,
notifications,
Popover,
TextArea,
} from "@budibase/bbui"
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
@ -69,8 +63,7 @@
import { validateHbsTemplate } from "./validator/hbs"
import { validateJsTemplate } from "./validator/js"
import { featureFlag } from "@/helpers"
import { API } from "@/api"
import Spinner from "../Spinner.svelte"
import AIGen from "./AIGen.svelte"
export let label: string | undefined = undefined
export let completions: BindingCompletion[] = []
@ -94,15 +87,19 @@
let mounted = false
let isEditorInitialised = false
let queuedRefresh = false
let editorWidth: number | null = null
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
let popoverAnchor: HTMLElement
let popover: Popover
let promptInput: TextArea
const updateEditorWidth = () => {
if (editorEle) {
editorWidth = editorEle.offsetWidth
}
}
$: aiGenEnabled =
featureFlag.isEnabled(FeatureFlag.AI_JS_GENERATION) &&
mode.name === "javascript" &&
@ -161,68 +158,6 @@
}
}
$: promptLoading = false
let popoverWidth = 300
let suggestedCode: string | null = null
let previousContents: string | null = null
const generateJs = async (prompt: string) => {
previousContents = editor.state.doc.toString()
promptLoading = true
popoverWidth = 30
let code = ""
try {
const resp = await API.generateJs({ prompt, bindings })
code = resp.code
if (code === "") {
throw new Error(
"we didn't understand your prompt, please phrase your request in another way"
)
}
} catch (e) {
console.error(e)
if (e instanceof Error) {
notifications.error(`Unable to generate code: ${e.message}`)
} else {
notifications.error("Unable to generate code, please try again later.")
}
code = previousContents
promptLoading = false
resetPopover()
return
}
value = code
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code },
})
suggestedCode = code
popoverWidth = 100
promptLoading = false
}
const acceptSuggestion = () => {
suggestedCode = null
previousContents = null
resetPopover()
dispatch("change", editor.state.doc.toString())
dispatch("blur", editor.state.doc.toString())
}
const rejectSuggestion = () => {
suggestedCode = null
value = previousContents || ""
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value },
})
previousContents = null
resetPopover()
}
const resetPopover = () => {
popover.hide()
popoverWidth = 300
}
// Export a function to expose caret position
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
@ -487,12 +422,32 @@
})
}
onMount(async () => {
// Handle AI generation code updates
const handleAICodeUpdate = (event: CustomEvent<{ code: string }>) => {
const { code } = event.detail
value = code
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code },
})
}
onMount(() => {
mounted = true
// Capture scrolling
editorEle.addEventListener("wheel", e => {
e.stopPropagation()
})
// Need to get the width of the drawer to pass to the prompt component
updateEditorWidth()
const resizeObserver = new ResizeObserver(() => {
updateEditorWidth()
})
resizeObserver.observe(editorEle)
return () => {
resizeObserver.disconnect()
}
})
onDestroy(() => {
@ -513,52 +468,23 @@
</div>
{#if aiGenEnabled}
<button
bind:this={popoverAnchor}
class="ai-gen"
on:click={() => {
popover.show()
setTimeout(() => {
promptInput.focus()
}, 100)
<AIGen
{bindings}
{value}
parentWidth={editorWidth}
on:update={handleAICodeUpdate}
on:accept={() => {
dispatch("change", editor.state.doc.toString())
dispatch("blur", editor.state.doc.toString())
}}
>
Generate with AI ✨
</button>
<Popover
bind:this={popover}
minWidth={popoverWidth}
anchor={popoverAnchor}
on:close={() => {
if (suggestedCode) {
acceptSuggestion()
}
on:reject={event => {
const { code } = event.detail
value = code
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code },
})
}}
align="left-outside"
>
{#if promptLoading}
<div class="prompt-spinner">
<Spinner size="20" color="white" />
</div>
{:else if suggestedCode !== null}
<Button on:click={acceptSuggestion}>Accept</Button>
<Button on:click={rejectSuggestion}>Reject</Button>
{:else}
<TextArea
bind:this={promptInput}
placeholder="Type your prompt then press enter..."
on:keypress={event => {
if (event.getModifierState("Shift")) {
return
}
if (event.key === "Enter") {
generateJs(promptInput.contents())
}
}}
/>
{/if}
</Popover>
/>
{/if}
<style>
@ -766,34 +692,4 @@
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.ai-gen {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
padding: var(--spacing-s);
border-left: 1px solid var(--spectrum-alias-border-color);
border-top: 1px solid var(--spectrum-alias-border-color);
border-top-left-radius: var(--spectrum-alias-border-radius-regular);
color: var(--spectrum-global-color-blue-700);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.ai-gen:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.prompt-spinner {
padding: var(--spacing-m);
}
</style>

@ -1 +1 @@
Subproject commit dcf65b2361ae31aab0ccfadd8222d3aa3c421181
Subproject commit 234babe3609467eb090846881be58c096415ec73