Compare commits
3 Commits
wmill-scri
...
draftgloba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da64385123 | ||
|
|
22b480cffe | ||
|
|
b406428ace |
@@ -16,7 +16,9 @@
|
|||||||
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
|
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
|
||||||
import Button from '$lib/components/common/button/Button.svelte'
|
import Button from '$lib/components/common/button/Button.svelte'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||||
|
|
||||||
|
export let id: string = 'dropdown-v2'
|
||||||
export let items: Item[] | (() => Item[]) | (() => Promise<Item[]>) = []
|
export let items: Item[] | (() => Item[]) | (() => Promise<Item[]>) = []
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let placement: Placement = 'bottom-end'
|
export let placement: Placement = 'bottom-end'
|
||||||
@@ -27,6 +29,9 @@
|
|||||||
export let open = false
|
export let open = false
|
||||||
export let customWidth: number | undefined = undefined
|
export let customWidth: number | undefined = undefined
|
||||||
export let customMenu = false
|
export let customMenu = false
|
||||||
|
export let enableTriggerableByAI = false
|
||||||
|
|
||||||
|
let buttonEl = { click: () => {} }
|
||||||
|
|
||||||
const {
|
const {
|
||||||
elements: { menu, item, trigger },
|
elements: { menu, item, trigger },
|
||||||
@@ -76,36 +81,44 @@
|
|||||||
|
|
||||||
<ResolveOpen {open} on:open on:close />
|
<ResolveOpen {open} on:open on:close />
|
||||||
|
|
||||||
<button
|
<TriggerableByAI
|
||||||
class={twMerge('w-full flex items-center justify-end', fixedHeight && 'h-8', $$props.class)}
|
{id}
|
||||||
use:melt={$trigger}
|
description="Open dropdown"
|
||||||
{disabled}
|
onTrigger={() => buttonEl.click()}
|
||||||
on:click={(e) => e.stopPropagation()}
|
disabled={!enableTriggerableByAI}
|
||||||
use:pointerDownOutside={{
|
|
||||||
capture: true,
|
|
||||||
stopPropagation: false,
|
|
||||||
exclude: getMenuElements,
|
|
||||||
customEventName: 'pointerdown_menu'
|
|
||||||
}}
|
|
||||||
on:pointerdown_outside={() => {
|
|
||||||
if (usePointerDownOutside) {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-menu
|
|
||||||
>
|
>
|
||||||
{#if $$slots.buttonReplacement}
|
<button
|
||||||
<slot name="buttonReplacement" />
|
bind:this={buttonEl}
|
||||||
{:else}
|
class={twMerge('w-full flex items-center justify-end', fixedHeight && 'h-8', $$props.class)}
|
||||||
<Button
|
use:melt={$trigger}
|
||||||
nonCaptureEvent
|
{disabled}
|
||||||
size="xs"
|
on:click={(e) => e.stopPropagation()}
|
||||||
color="light"
|
use:pointerDownOutside={{
|
||||||
startIcon={{ icon: MoreVertical }}
|
capture: true,
|
||||||
btnClasses="bg-transparent"
|
stopPropagation: false,
|
||||||
/>
|
exclude: getMenuElements,
|
||||||
{/if}
|
customEventName: 'pointerdown_menu'
|
||||||
</button>
|
}}
|
||||||
|
on:pointerdown_outside={() => {
|
||||||
|
if (usePointerDownOutside) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-menu
|
||||||
|
>
|
||||||
|
{#if $$slots.buttonReplacement}
|
||||||
|
<slot name="buttonReplacement" />
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
nonCaptureEvent
|
||||||
|
size="xs"
|
||||||
|
color="light"
|
||||||
|
startIcon={{ icon: MoreVertical }}
|
||||||
|
btnClasses="bg-transparent"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</TriggerableByAI>
|
||||||
|
|
||||||
{#if open && !hidePopup}
|
{#if open && !hidePopup}
|
||||||
<div use:melt={$menu} data-menu class="z-[6000] transition-all duration-100">
|
<div use:melt={$menu} data-menu class="z-[6000] transition-all duration-100">
|
||||||
@@ -116,7 +129,7 @@
|
|||||||
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
|
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
|
||||||
style={customWidth ? `width: ${customWidth}px` : ''}
|
style={customWidth ? `width: ${customWidth}px` : ''}
|
||||||
>
|
>
|
||||||
<DropdownV2Inner items={computeItems} meltItem={item} />
|
<DropdownV2Inner {id} items={computeItems} meltItem={item} {enableTriggerableByAI} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,22 @@
|
|||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||||
import type { Item } from '$lib/utils'
|
import type { Item } from '$lib/utils'
|
||||||
|
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
id?: string
|
||||||
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
|
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
|
||||||
meltItem: MenubarMenuElements['item']
|
meltItem: MenubarMenuElements['item']
|
||||||
|
enableTriggerableByAI?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let { items = [], meltItem }: Props = $props()
|
let {
|
||||||
|
id = 'dropdown-v2-inner',
|
||||||
|
items = [],
|
||||||
|
meltItem,
|
||||||
|
enableTriggerableByAI = false
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
let computedItems: Item[] | undefined = $state(undefined)
|
let computedItems: Item[] | undefined = $state(undefined)
|
||||||
async function computeItems() {
|
async function computeItems() {
|
||||||
@@ -27,29 +36,44 @@
|
|||||||
{#if computedItems}
|
{#if computedItems}
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
{#each computedItems ?? [] as item}
|
{#each computedItems ?? [] as item}
|
||||||
<MenuItem
|
<TriggerableByAI
|
||||||
on:click={(e) => item?.action?.(e)}
|
id={`${id}-${item.displayName}`}
|
||||||
href={item?.href}
|
description={item.displayName}
|
||||||
disabled={item?.disabled}
|
onTrigger={() => {
|
||||||
class={twMerge(
|
console.log('triggering', item)
|
||||||
'px-4 py-2 text-primary font-semibold hover:bg-surface-hover cursor-pointer text-xs transition-all',
|
if (item.action) {
|
||||||
'data-[highlighted]:bg-surface-hover',
|
item.action({} as MouseEvent)
|
||||||
'flex flex-row gap-2 items-center',
|
}
|
||||||
item?.disabled && 'text-gray-400 cursor-not-allowed',
|
if (item.href) {
|
||||||
item?.type === 'delete' &&
|
goto(item.href)
|
||||||
!item?.disabled &&
|
}
|
||||||
'text-red-500 hover:bg-red-100 hover:text-red-500 data-[highlighted]:text-red-500 data-[highlighted]:bg-red-100'
|
}}
|
||||||
)}
|
disabled={!enableTriggerableByAI}
|
||||||
item={meltItem}
|
|
||||||
>
|
>
|
||||||
{#if item.icon}
|
<MenuItem
|
||||||
<item.icon size={14} color={item.iconColor} />
|
on:click={(e) => item?.action?.(e)}
|
||||||
{/if}
|
href={item?.href}
|
||||||
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
|
disabled={item?.disabled}
|
||||||
{item.displayName}
|
class={twMerge(
|
||||||
</p>
|
'px-4 py-2 text-primary font-semibold hover:bg-surface-hover cursor-pointer text-xs transition-all',
|
||||||
{@render item.extra?.()}
|
'data-[highlighted]:bg-surface-hover',
|
||||||
</MenuItem>
|
'flex flex-row gap-2 items-center',
|
||||||
|
item?.disabled && 'text-gray-400 cursor-not-allowed',
|
||||||
|
item?.type === 'delete' &&
|
||||||
|
!item?.disabled &&
|
||||||
|
'text-red-500 hover:bg-red-100 hover:text-red-500 data-[highlighted]:text-red-500 data-[highlighted]:bg-red-100'
|
||||||
|
)}
|
||||||
|
item={meltItem}
|
||||||
|
>
|
||||||
|
{#if item.icon}
|
||||||
|
<item.icon size={14} color={item.iconColor} />
|
||||||
|
{/if}
|
||||||
|
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
|
||||||
|
{item.displayName}
|
||||||
|
</p>
|
||||||
|
{@render item.extra?.()}
|
||||||
|
</MenuItem>
|
||||||
|
</TriggerableByAI>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
type Script,
|
type Script,
|
||||||
type TriggersCount,
|
type TriggersCount,
|
||||||
PostgresTriggerService,
|
PostgresTriggerService,
|
||||||
CaptureService
|
CaptureService,
|
||||||
|
type ScriptLang
|
||||||
} from '$lib/gen'
|
} from '$lib/gen'
|
||||||
import { inferArgs } from '$lib/infer'
|
import { inferArgs } from '$lib/infer'
|
||||||
import { initialCode } from '$lib/script_helpers'
|
import { initialCode } from '$lib/script_helpers'
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
} from './triggers/utils'
|
} from './triggers/utils'
|
||||||
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
||||||
import { Triggers } from './triggers/triggers.svelte'
|
import { Triggers } from './triggers/triggers.svelte'
|
||||||
|
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||||
|
|
||||||
export let script: NewScript & { draft_triggers?: Trigger[] }
|
export let script: NewScript & { draft_triggers?: Trigger[] }
|
||||||
export let fullyLoaded: boolean = true
|
export let fullyLoaded: boolean = true
|
||||||
@@ -869,6 +871,47 @@
|
|||||||
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
|
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) {
|
||||||
|
if (lang == 'docker') {
|
||||||
|
if (isCloudHosted()) {
|
||||||
|
sendUserToast(
|
||||||
|
'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.',
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Learn more',
|
||||||
|
callback: () => {
|
||||||
|
window.open('https://www.windmill.dev/docs/advanced/docker', '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
template = 'docker'
|
||||||
|
} else if (lang == 'bunnative') {
|
||||||
|
template = 'bunnative'
|
||||||
|
} else {
|
||||||
|
template = 'script'
|
||||||
|
}
|
||||||
|
let language = langToLanguage(lang)
|
||||||
|
//
|
||||||
|
initContent(language, script.kind, template)
|
||||||
|
script.language = language
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSummaryChange(value: string) {
|
||||||
|
if (initialPath == '' && value?.length > 0 && !dirtyPath) {
|
||||||
|
path?.setName(
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_]/g, '_')
|
||||||
|
.replace(/-+/g, '_')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={onKeyDown} />
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
@@ -947,29 +990,31 @@
|
|||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<Label label="Summary">
|
<Label label="Summary">
|
||||||
<MetadataGen
|
<TriggerableByAI
|
||||||
label="Summary"
|
id="create-script-summary-input"
|
||||||
bind:content={script.summary}
|
description="Summary / Title of the new script"
|
||||||
lang={script.language}
|
onTrigger={(value) => {
|
||||||
code={script.content}
|
console.log('Triggering example component with value', value)
|
||||||
promptConfigName="summary"
|
if (value) {
|
||||||
generateOnAppear
|
script.summary = value
|
||||||
on:change={() => {
|
onSummaryChange(value)
|
||||||
if (initialPath == '' && script.summary?.length > 0 && !dirtyPath) {
|
|
||||||
path?.setName(
|
|
||||||
script.summary
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9_]/g, '_')
|
|
||||||
.replace(/-+/g, '_')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
elementProps={{
|
>
|
||||||
type: 'text',
|
<MetadataGen
|
||||||
placeholder: 'Short summary to be displayed when listed'
|
label="Summary"
|
||||||
}}
|
bind:content={script.summary}
|
||||||
/>
|
lang={script.language}
|
||||||
|
code={script.content}
|
||||||
|
promptConfigName="summary"
|
||||||
|
generateOnAppear
|
||||||
|
on:change={() => onSummaryChange(script.summary)}
|
||||||
|
elementProps={{
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Short summary to be displayed when listed'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TriggerableByAI>
|
||||||
</Label>
|
</Label>
|
||||||
<Label label="Path">
|
<Label label="Path">
|
||||||
<svelte:fragment slot="header">
|
<svelte:fragment slot="header">
|
||||||
@@ -1022,53 +1067,32 @@
|
|||||||
<Popover
|
<Popover
|
||||||
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
|
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
|
||||||
>
|
>
|
||||||
<Button
|
<TriggerableByAI
|
||||||
size="sm"
|
id={`create-script-language-button-${lang}`}
|
||||||
variant="border"
|
description={`Choose ${lang} as the language of the script`}
|
||||||
color={isPicked ? 'blue' : 'light'}
|
onTrigger={() => {
|
||||||
btnClasses={isPicked
|
console.log('Triggering example component', lang)
|
||||||
? '!border-2 !bg-blue-50/75 dark:!bg-frost-900/75'
|
onScriptLanguageTrigger(lang)
|
||||||
: 'm-[1px]'}
|
|
||||||
on:click={() => {
|
|
||||||
if (lang == 'docker') {
|
|
||||||
if (isCloudHosted()) {
|
|
||||||
sendUserToast(
|
|
||||||
'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.',
|
|
||||||
true,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: 'Learn more',
|
|
||||||
callback: () => {
|
|
||||||
window.open(
|
|
||||||
'https://www.windmill.dev/docs/advanced/docker',
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
template = 'docker'
|
|
||||||
} else if (lang == 'bunnative') {
|
|
||||||
template = 'bunnative'
|
|
||||||
} else {
|
|
||||||
template = 'script'
|
|
||||||
}
|
|
||||||
let language = langToLanguage(lang)
|
|
||||||
//
|
|
||||||
initContent(language, script.kind, template)
|
|
||||||
script.language = language
|
|
||||||
}}
|
}}
|
||||||
disabled={lockedLanguage ||
|
|
||||||
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
|
|
||||||
>
|
>
|
||||||
<LanguageIcon {lang} />
|
<Button
|
||||||
<span class="ml-2 py-2 truncate">{label}</span>
|
size="sm"
|
||||||
{#if lang === 'ansible' || lang === 'nu'}
|
variant="border"
|
||||||
<span class="text-tertiary !text-xs"> BETA </span>
|
color={isPicked ? 'blue' : 'light'}
|
||||||
{/if}
|
btnClasses={isPicked
|
||||||
</Button>
|
? '!border-2 !bg-blue-50/75 dark:!bg-frost-900/75'
|
||||||
|
: 'm-[1px]'}
|
||||||
|
on:click={() => onScriptLanguageTrigger(lang)}
|
||||||
|
disabled={lockedLanguage ||
|
||||||
|
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
|
||||||
|
>
|
||||||
|
<LanguageIcon {lang} />
|
||||||
|
<span class="ml-2 py-2 truncate">{label}</span>
|
||||||
|
{#if lang === 'ansible' || lang === 'nu'}
|
||||||
|
<span class="text-tertiary !text-xs"> BETA </span>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</TriggerableByAI>
|
||||||
<svelte:fragment slot="text"
|
<svelte:fragment slot="text"
|
||||||
>{label} is only available with an enterprise license</svelte:fragment
|
>{label} is only available with an enterprise license</svelte:fragment
|
||||||
>
|
>
|
||||||
|
|||||||
96
frontend/src/lib/components/TriggerableByAI.svelte
Normal file
96
frontend/src/lib/components/TriggerableByAI.svelte
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { triggerablesByAI } from '$lib/stores'
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
onTrigger,
|
||||||
|
children,
|
||||||
|
disabled = false
|
||||||
|
} = $props<{
|
||||||
|
id: string
|
||||||
|
description: string
|
||||||
|
onTrigger: (value?: string) => void
|
||||||
|
children?: () => any
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Track animation state
|
||||||
|
let isAnimating = $state(false)
|
||||||
|
|
||||||
|
// Wrapper for onTrigger that adds animation
|
||||||
|
function handleTrigger(value?: string) {
|
||||||
|
if (disabled || !onTrigger) return
|
||||||
|
|
||||||
|
// Show animation
|
||||||
|
isAnimating = true
|
||||||
|
|
||||||
|
// Call the actual onTrigger
|
||||||
|
onTrigger(value)
|
||||||
|
|
||||||
|
// Reset animation state after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
isAnimating = false
|
||||||
|
}, 1200) // Animation duration
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (disabled) return
|
||||||
|
triggerablesByAI.update((triggers) => {
|
||||||
|
return { ...triggers, [id]: { description, onTrigger: handleTrigger } }
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
triggerablesByAI.update((triggers) => {
|
||||||
|
const newTriggers = { ...triggers }
|
||||||
|
delete newTriggers[id]
|
||||||
|
return newTriggers
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ai-triggerable-wrapper">
|
||||||
|
{#if isAnimating}
|
||||||
|
<div class="ai-triggerable-animation"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="ai-triggerable-content">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ai-triggerable-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-triggerable-content {
|
||||||
|
/* This preserves original styling of children */
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-triggerable-animation {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-color: rgba(66, 133, 244, 0.9);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: pulse 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-50%) scale(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%) scale(2.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -72,6 +72,10 @@
|
|||||||
element?.focus({})
|
element?.focus({})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function click() {
|
||||||
|
element?.click()
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
|
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
|
||||||
// Order of classes: border, border modifier, bg, bg modifier, text, text modifier, everything else
|
// Order of classes: border, border modifier, bg, bg modifier, text, text modifier, everything else
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||||
import { classNames } from '$lib/utils'
|
import { classNames } from '$lib/utils'
|
||||||
import CloseButton from '../CloseButton.svelte'
|
import CloseButton from '../CloseButton.svelte'
|
||||||
|
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
export let title: string | undefined = undefined
|
export let title: string | undefined = undefined
|
||||||
export let overflow_y = true
|
export let overflow_y = true
|
||||||
export let noPadding = false
|
export let noPadding = false
|
||||||
@@ -12,13 +13,22 @@
|
|||||||
export let CloseIcon: any | undefined = undefined
|
export let CloseIcon: any | undefined = undefined
|
||||||
|
|
||||||
export let fullScreen: boolean = true
|
export let fullScreen: boolean = true
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}>
|
<div class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}>
|
||||||
<div class="flex justify-between w-full items-center px-4 py-2 gap-2">
|
<div class="flex justify-between w-full items-center px-4 py-2 gap-2">
|
||||||
<div class="flex items-center gap-2 w-full truncate">
|
<div class="flex items-center gap-2 w-full truncate">
|
||||||
<CloseButton on:close Icon={CloseIcon} />
|
<TriggerableByAI
|
||||||
|
id={`close-drawer-button-${title?.toLowerCase().replace(/ /g, '-')}`}
|
||||||
|
description={`Closes the drawer titled ${title}`}
|
||||||
|
onTrigger={() => {
|
||||||
|
dispatch('close')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseButton on:close Icon={CloseIcon} />
|
||||||
|
</TriggerableByAI>
|
||||||
<span class="font-semibold truncate text-primary !text-lg max-w-sm"
|
<span class="font-semibold truncate text-primary !text-lg max-w-sm"
|
||||||
>{title ?? ''}
|
>{title ?? ''}
|
||||||
{#if tooltip != '' || documentationLink}
|
{#if tooltip != '' || documentationLink}
|
||||||
|
|||||||
@@ -165,6 +165,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
id={`script-row-dropdown-${script.path}-${script.summary}`}
|
||||||
|
enableTriggerableByAI
|
||||||
items={async () => {
|
items={async () => {
|
||||||
let owner = isOwner(script.path, $userStore, $workspaceStore)
|
let owner = isOwner(script.path, $userStore, $workspaceStore)
|
||||||
if (script.draft_only) {
|
if (script.draft_only) {
|
||||||
@@ -315,6 +317,7 @@
|
|||||||
if (event?.shiftKey) {
|
if (event?.shiftKey) {
|
||||||
deleteScript(script.path)
|
deleteScript(script.path)
|
||||||
} else {
|
} else {
|
||||||
|
console.log('setting delete confirmed callback')
|
||||||
deleteConfirmedCallback = () => {
|
deleteConfirmedCallback = () => {
|
||||||
deleteScript(script.path)
|
deleteScript(script.path)
|
||||||
}
|
}
|
||||||
|
|||||||
65
frontend/src/lib/components/globalchat/Input.svelte
Normal file
65
frontend/src/lib/components/globalchat/Input.svelte
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { chatRequest, prepareUserMessage, prepareSystemMessage } from './core'
|
||||||
|
|
||||||
|
// Using Svelte 5 runes for reactivity
|
||||||
|
let inputValue = $state('')
|
||||||
|
let isSubmitting = $state(false)
|
||||||
|
let currentReply = $state('')
|
||||||
|
|
||||||
|
let abortController = new AbortController()
|
||||||
|
|
||||||
|
// Props definition using $props
|
||||||
|
let { placeholder = 'Type a message...', buttonText = 'Send' } = $props()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!inputValue.trim()) return
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
currentReply = ''
|
||||||
|
|
||||||
|
const userMessage = prepareUserMessage(inputValue)
|
||||||
|
const systemMessage = prepareSystemMessage()
|
||||||
|
let messages = [systemMessage]
|
||||||
|
messages.push({ role: 'user', content: userMessage })
|
||||||
|
|
||||||
|
const result = await chatRequest(messages, abortController, (token) => {
|
||||||
|
currentReply = currentReply + token
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(result)
|
||||||
|
console.log(currentReply)
|
||||||
|
|
||||||
|
// Reset the input field
|
||||||
|
inputValue = ''
|
||||||
|
isSubmitting = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<form
|
||||||
|
class="flex w-full gap-2"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
bind:value={inputValue}
|
||||||
|
{placeholder}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
disabled={isSubmitting || !inputValue.trim()}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="flex flex-row border rounded-md p-2">
|
||||||
|
<p>{currentReply}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
248
frontend/src/lib/components/globalchat/core.ts
Normal file
248
frontend/src/lib/components/globalchat/core.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { get, type Writable } from 'svelte/store'
|
||||||
|
import { getCompletion } from '$lib/components/copilot/lib'
|
||||||
|
import type {
|
||||||
|
ChatCompletionChunk,
|
||||||
|
ChatCompletionMessageParam,
|
||||||
|
ChatCompletionMessageToolCall,
|
||||||
|
ChatCompletionTool
|
||||||
|
} from 'openai/resources/index.mjs'
|
||||||
|
import { triggerablesByAI } from '$lib/stores'
|
||||||
|
|
||||||
|
// System prompt for the LLM
|
||||||
|
export const CHAT_SYSTEM_PROMPT = `
|
||||||
|
You are an assistant that can interact with the user's web page in order to help them find and do things.
|
||||||
|
You have access to tools that let you:
|
||||||
|
1. View the current triggerable components on the page
|
||||||
|
2. Execute the trigger function of a triggerable component
|
||||||
|
|
||||||
|
When asked to interact with the page:
|
||||||
|
- First examine the page structure to understand what's available
|
||||||
|
- Explain what you're doing before taking action
|
||||||
|
- Take action only if you're sure it's what the user wants
|
||||||
|
- After executing a command, wait for 1 second before rechecking the page and continuing fulfulling the user request. At each step, explain what you're doing before taking action.
|
||||||
|
- After fulfilling the user request, if there is a close button associated with a drawer or settings panel, use it to close the drawer or settings panel.
|
||||||
|
|
||||||
|
Use the provided tools only when necessary and appropriate.
|
||||||
|
`
|
||||||
|
|
||||||
|
// Tool definitions
|
||||||
|
const GET_PAGE_HTML_TOOL: ChatCompletionTool = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_triggerable_components',
|
||||||
|
description: 'Get the current triggerable components on the page',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXECUTE_COMMAND_TOOL: ChatCompletionTool = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'trigger_component',
|
||||||
|
description: 'Trigger a triggerable component',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'ID of the AI-triggerable component'
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Value to pass to the AI-triggerable component trigger function'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['id']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get page HTML
|
||||||
|
function getTriggerableComponents(): string {
|
||||||
|
try {
|
||||||
|
// Get components registered in the triggerablesByAI store
|
||||||
|
const registeredComponents = get(triggerablesByAI)
|
||||||
|
let result = 'TRIGGERABLE_COMPONENTS:\n'
|
||||||
|
|
||||||
|
// If there are no components registered, return a message
|
||||||
|
if (Object.keys(registeredComponents).length === 0) {
|
||||||
|
return 'No AI-triggerable components are currently available on this page.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// List each registered component with its ID and description
|
||||||
|
Object.entries(registeredComponents).forEach(([id, component], index) => {
|
||||||
|
result += `[${index}] ID: "${id}" - ${component.description}\n`
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting triggerable components:', error)
|
||||||
|
return 'Error getting triggerable components: ' + error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to execute commands on the page
|
||||||
|
function triggerComponent(args: { id: string; value: string }): string {
|
||||||
|
const { id, value } = args
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle triggering AI components
|
||||||
|
if (!id) {
|
||||||
|
return 'Trigger command requires an id parameter'
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = get(triggerablesByAI)
|
||||||
|
const component = components[id]
|
||||||
|
|
||||||
|
if (!component) {
|
||||||
|
return `No triggerable component found with id: ${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component.onTrigger) {
|
||||||
|
component.onTrigger(value)
|
||||||
|
return `Successfully triggered component: ${id} (${component.description})`
|
||||||
|
} else {
|
||||||
|
return `Component ${id} has no trigger handler defined`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing command:', error)
|
||||||
|
return `Error executing command: ${error.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tool calls from the LLM
|
||||||
|
async function processToolCall(
|
||||||
|
toolCall: ChatCompletionMessageToolCall,
|
||||||
|
messages: ChatCompletionMessageParam[]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (toolCall.function.name === 'get_triggerable_components') {
|
||||||
|
result = getTriggerableComponents()
|
||||||
|
} else if (toolCall.function.name === 'trigger_component') {
|
||||||
|
result = triggerComponent(args)
|
||||||
|
} else {
|
||||||
|
result = `Unknown tool: ${toolCall.function.name}`
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
result = `Error while calling ${toolCall.function.name}: ${err.message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
content: result
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to handle chat requests
|
||||||
|
export async function chatRequest(
|
||||||
|
messages: ChatCompletionMessageParam[],
|
||||||
|
abortController: AbortController,
|
||||||
|
onNewToken: (token: string) => void
|
||||||
|
) {
|
||||||
|
const toolDefs: ChatCompletionTool[] = [GET_PAGE_HTML_TOOL, EXECUTE_COMMAND_TOOL]
|
||||||
|
|
||||||
|
try {
|
||||||
|
let completion: any = null
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
completion = await getCompletion(messages, abortController, toolDefs)
|
||||||
|
console.log(completion)
|
||||||
|
|
||||||
|
if (completion) {
|
||||||
|
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
|
||||||
|
|
||||||
|
for await (const chunk of completion) {
|
||||||
|
if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = chunk as ChatCompletionChunk
|
||||||
|
const delta = c.choices[0].delta.content
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
onNewToken(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls = c.choices[0].delta.tool_calls || []
|
||||||
|
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
const { index } = toolCall
|
||||||
|
const finalToolCall = finalToolCalls[index]
|
||||||
|
|
||||||
|
if (!finalToolCall) {
|
||||||
|
finalToolCalls[index] = toolCall
|
||||||
|
} else {
|
||||||
|
if (toolCall.function?.arguments) {
|
||||||
|
if (!finalToolCall.function) {
|
||||||
|
finalToolCall.function = toolCall.function
|
||||||
|
} else {
|
||||||
|
finalToolCall.function.arguments =
|
||||||
|
(finalToolCall.function.arguments ?? '') + toolCall.function.arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls = Object.values(finalToolCalls).filter(
|
||||||
|
(toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined
|
||||||
|
) as ChatCompletionMessageToolCall[]
|
||||||
|
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
tool_calls: toolCalls
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
await processToolCall(toolCall, messages)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completion
|
||||||
|
} catch (err) {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare initial system message
|
||||||
|
export function prepareSystemMessage(): ChatCompletionMessageParam {
|
||||||
|
return {
|
||||||
|
role: 'system',
|
||||||
|
content: CHAT_SYSTEM_PROMPT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare user message with context
|
||||||
|
export function prepareUserMessage(message: string): string {
|
||||||
|
return `
|
||||||
|
MESSAGE: ${message}
|
||||||
|
|
||||||
|
Feel free to use the get_page_html tool first if you need to understand the current page structure.
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for chat context
|
||||||
|
export interface AIChatContext {
|
||||||
|
loading: Writable<boolean>
|
||||||
|
currentReply: Writable<string>
|
||||||
|
}
|
||||||
@@ -2,11 +2,26 @@
|
|||||||
import { Code2, Plus } from 'lucide-svelte'
|
import { Code2, Plus } from 'lucide-svelte'
|
||||||
import Button from '../common/button/Button.svelte'
|
import Button from '../common/button/Button.svelte'
|
||||||
import { base } from '$lib/base'
|
import { base } from '$lib/base'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
// Reference to the button component
|
||||||
|
let buttonComponent: { click: () => void } | undefined = undefined
|
||||||
|
|
||||||
|
export function triggerClick() {
|
||||||
|
// Navigate to the script creation page directly
|
||||||
|
// goto(`${base}/scripts/add`)
|
||||||
|
|
||||||
|
// Focus the button for visual feedback
|
||||||
|
if (buttonComponent) {
|
||||||
|
buttonComponent.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
bind:this={buttonComponent}
|
||||||
size="sm"
|
size="sm"
|
||||||
spacingSize="xl"
|
spacingSize="xl"
|
||||||
color="marine"
|
color="marine"
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ export const copilotSessionModel = writable<AIProviderModel | undefined>(
|
|||||||
)
|
)
|
||||||
export const usedTriggerKinds = writable<string[]>([])
|
export const usedTriggerKinds = writable<string[]>([])
|
||||||
|
|
||||||
|
export const triggerablesByAI = writable<
|
||||||
|
Record<string, { description: string; onTrigger: (id: string) => void }>
|
||||||
|
>({})
|
||||||
|
|
||||||
type SQLBaseSchema = {
|
type SQLBaseSchema = {
|
||||||
[schemaKey: string]: {
|
[schemaKey: string]: {
|
||||||
[tableKey: string]: {
|
[tableKey: string]: {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
import { setContext } from 'svelte'
|
import { setContext } from 'svelte'
|
||||||
import { base } from '$app/paths'
|
import { base } from '$app/paths'
|
||||||
import { Menubar } from '$lib/components/meltComponents'
|
import { Menubar } from '$lib/components/meltComponents'
|
||||||
|
import Input from '$lib/components/globalchat/Input.svelte'
|
||||||
|
|
||||||
OpenAPI.WITH_CREDENTIALS = true
|
OpenAPI.WITH_CREDENTIALS = true
|
||||||
let menuOpen = false
|
let menuOpen = false
|
||||||
@@ -630,6 +631,7 @@
|
|||||||
>
|
>
|
||||||
<main class="min-h-screen">
|
<main class="min-h-screen">
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
|
<Input />
|
||||||
<div
|
<div
|
||||||
class={classNames(
|
class={classNames(
|
||||||
'py-2 px-2 sm:px-4 md:px-8 flex justify-between items-center shadow-sm max-w-7xl mx-auto md:hidden',
|
'py-2 px-2 sm:px-4 md:px-8 flex justify-between items-center shadow-sm max-w-7xl mx-auto md:hidden',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import { setQuery } from '$lib/navigation'
|
import { setQuery } from '$lib/navigation'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { goto, replaceState } from '$app/navigation'
|
import { goto, replaceState } from '$app/navigation'
|
||||||
|
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||||
|
|
||||||
type Tab = 'hub' | 'workspace'
|
type Tab = 'hub' | 'workspace'
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@
|
|||||||
|
|
||||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||||
|
|
||||||
|
let createScriptComponent: { triggerClick: () => void } | undefined = undefined
|
||||||
|
|
||||||
async function viewCode(obj: HubItem) {
|
async function viewCode(obj: HubItem) {
|
||||||
codeViewerContent = ''
|
codeViewerContent = ''
|
||||||
codeViewerObj = undefined
|
codeViewerObj = undefined
|
||||||
@@ -227,7 +230,17 @@
|
|||||||
<div class="flex flex-row gap-4 flex-wrap justify-end items-center">
|
<div class="flex flex-row gap-4 flex-wrap justify-end items-center">
|
||||||
{#if !$userStore?.operator}
|
{#if !$userStore?.operator}
|
||||||
<span class="text-sm text-secondary">Create a</span>
|
<span class="text-sm text-secondary">Create a</span>
|
||||||
<CreateActionsScript />
|
<TriggerableByAI
|
||||||
|
id="create-script-button"
|
||||||
|
description="Creates a new script"
|
||||||
|
onTrigger={() => {
|
||||||
|
if (createScriptComponent) {
|
||||||
|
createScriptComponent.triggerClick()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CreateActionsScript bind:this={createScriptComponent} />
|
||||||
|
</TriggerableByAI>
|
||||||
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
|
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
|
||||||
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
|
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user