mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat: add navigator mode to AIChat and unify UI (#5859)
* feat: ai flow chat * youpi * feat: preprocessor and error handler support * fix: reactivity * Add GlobalChat component with drawer functionality - Create GlobalChat.svelte with placeholder chat functionality - Create GlobalChatDrawer.svelte as drawer wrapper - Add global chat button to sidebar menu (both mobile and desktop) - Integrate global chat state management in main layout - Include message history, loading states, and error handling - Implement responsive design and proper drawer behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com> * draft * use triggerable by ai compoennt * make drawer triggerable * implement logic * add inkeep tool * cleaner code * make more things available * more integrations + better system prompt * fix docs fetching * small fix * cleaning * add ask in search bar + right top icon on homepage + suggestions * fix button * disable chat if no ai providers * add inkeep endpoint * draft working stuff * cleaner code * better chat * fix * send license and uid * better anim * move logic * parse links in chat * add missing integration * add reset button * fix * rm file * integrate navigator mode * integrate all changes * add hide button * adjust drawer size * add script ai chat integration * fix drawer * small fixes * small fixes * draft * merge script ai chat with global one * cleaning * fixes * working draft * add aichat service * cleaning more * remove left over from store * more descriptive states * better icon * fix * use pending prompt * cleaning * cleaning * small fix * add inkeep file * clean * add route * Update backend/windmill-api/src/lib.rs Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * cleaning * fix drawer * save open state in local storage * small fix * fixes * small fixes * move chat request to manager * renaming * move flow effects in manager * move chat effects in manager * remove log * Update frontend/src/lib/components/copilot/CronGen.svelte Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update frontend/src/lib/components/copilot/chat/flow/core.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * change askaibutton folder * define button * ifelse * no any + no default size * use tailwind * use splitpanes * move effects * remove deprecated file * wording * add back disable ai * add error message * modify system prompt * handle confirmation modal * fix * fix * close script settings * fix icon color * fix * fix history manager * fix test panel * save size * remove floating button * fix delete chat * fix * better fix --------- Co-authored-by: HugoCasa <hugo@casademont.ch> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
#[cfg(feature = "private")]
|
||||
#[allow(unused)]
|
||||
pub use crate::inkeep_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use axum::Router;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
@@ -93,6 +93,9 @@ pub mod http_triggers;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod indexer_ee;
|
||||
mod indexer_oss;
|
||||
#[cfg(feature = "private")]
|
||||
mod inkeep_ee;
|
||||
mod inkeep_oss;
|
||||
mod inputs;
|
||||
mod integration;
|
||||
#[cfg(feature = "postgres_trigger")]
|
||||
@@ -619,6 +622,7 @@ pub async fn run_server(
|
||||
.nest("/schedules", schedule::global_service())
|
||||
.nest("/embeddings", embeddings::global_service())
|
||||
.nest("/ai", ai::global_service())
|
||||
.nest("/inkeep", inkeep_oss::global_service())
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.route_layer(from_extractor::<users::Tokened>())
|
||||
.nest("/jobs", jobs::global_root_service())
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
(x) =>
|
||||
passwords.includes(x) ||
|
||||
['token', 'secret', 'key', 'pass', 'private'].some((y) => x.toLowerCase().includes(y))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let linkedSecret: string | undefined = undefined
|
||||
@@ -487,6 +487,8 @@
|
||||
{#if !nativeLanguagesCategory.includes(key)}
|
||||
<!-- Exclude specific items -->
|
||||
<Button
|
||||
aiId={`app-connect-inner-${key}`}
|
||||
aiDescription={`Connect to ${key}`}
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={key === resourceType ? 'blue' : 'light'}
|
||||
@@ -552,7 +554,8 @@
|
||||
{#if renderDescription}
|
||||
<div>
|
||||
<div class="flex flex-row-reverse text-2xs text-tertiary -mt-1">GH Markdown</div>
|
||||
<textarea use:autosize bind:value={description} placeholder={'Resource description'}></textarea>
|
||||
<textarea use:autosize bind:value={description} placeholder={'Resource description'}
|
||||
></textarea>
|
||||
</div>
|
||||
{:else if description == undefined || description == ''}
|
||||
<div class="text-sm text-tertiary">No description provided</div>
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let items: Item[] | (() => Item[]) | (() => Promise<Item[]>) = []
|
||||
export let disabled = false
|
||||
export let placement: Placement = 'bottom-end'
|
||||
@@ -28,6 +31,8 @@
|
||||
export let customWidth: number | undefined = undefined
|
||||
export let customMenu = false
|
||||
|
||||
let buttonEl: HTMLButtonElement | undefined = undefined
|
||||
|
||||
const {
|
||||
elements: { menu, item, trigger },
|
||||
states,
|
||||
@@ -76,36 +81,43 @@
|
||||
|
||||
<ResolveOpen {open} on:open on:close />
|
||||
|
||||
<button
|
||||
class={twMerge('w-full flex items-center justify-end', fixedHeight && 'h-8', $$props.class)}
|
||||
use:melt={$trigger}
|
||||
{disabled}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
use:pointerDownOutside={{
|
||||
capture: true,
|
||||
stopPropagation: false,
|
||||
exclude: getMenuElements,
|
||||
customEventName: 'pointerdown_menu'
|
||||
}}
|
||||
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 id={aiId} description={aiDescription} onTrigger={() => buttonEl?.click()}>
|
||||
<button
|
||||
bind:this={buttonEl}
|
||||
class={twMerge(
|
||||
'w-full flex items-center justify-end h-full',
|
||||
fixedHeight && 'h-8',
|
||||
$$props.class
|
||||
)}
|
||||
use:melt={$trigger}
|
||||
{disabled}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
use:pointerDownOutside={{
|
||||
capture: true,
|
||||
stopPropagation: false,
|
||||
exclude: getMenuElements,
|
||||
customEventName: 'pointerdown_menu'
|
||||
}}
|
||||
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}
|
||||
<div use:melt={$menu} data-menu class="z-[6000] transition-all duration-100">
|
||||
@@ -116,7 +128,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]"
|
||||
style={customWidth ? `width: ${customWidth}px` : ''}
|
||||
>
|
||||
<DropdownV2Inner items={computeItems} meltItem={item} />
|
||||
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
interface Props {
|
||||
aiId?: string
|
||||
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
|
||||
meltItem: MenubarMenuElements['item']
|
||||
}
|
||||
|
||||
let { items = [], meltItem }: Props = $props()
|
||||
let { aiId, items = [], meltItem }: Props = $props()
|
||||
|
||||
let computedItems: Item[] | undefined = $state(undefined)
|
||||
async function computeItems() {
|
||||
@@ -27,29 +30,42 @@
|
||||
{#if computedItems}
|
||||
<div class="flex flex-col">
|
||||
{#each computedItems ?? [] as item}
|
||||
<MenuItem
|
||||
on:click={(e) => item?.action?.(e)}
|
||||
href={item?.href}
|
||||
disabled={item?.disabled}
|
||||
class={twMerge(
|
||||
'px-4 py-2 text-primary font-semibold hover:bg-surface-hover cursor-pointer text-xs transition-all',
|
||||
'data-[highlighted]:bg-surface-hover',
|
||||
'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}
|
||||
<TriggerableByAI
|
||||
id={`${aiId ? `${aiId}-${item.displayName}` : undefined}`}
|
||||
description={item.displayName}
|
||||
onTrigger={() => {
|
||||
if (item.action) {
|
||||
item.action({} as MouseEvent)
|
||||
}
|
||||
if (item.href) {
|
||||
goto(item.href)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#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>
|
||||
<MenuItem
|
||||
on:click={(e) => item?.action?.(e)}
|
||||
href={item?.href}
|
||||
disabled={item?.disabled}
|
||||
class={twMerge(
|
||||
'px-4 py-2 text-primary font-semibold hover:bg-surface-hover cursor-pointer text-xs transition-all w-full',
|
||||
'data-[highlighted]:bg-surface-hover',
|
||||
'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}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -594,6 +594,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#if showContextVarPicker && customUi?.contextVar != false}
|
||||
<Button
|
||||
aiId="editor-bar-add-context-variable"
|
||||
aiDescription="Add context variable"
|
||||
title="Add context variable"
|
||||
color="light"
|
||||
on:click={contextualVariablePicker.openDrawer}
|
||||
@@ -607,6 +609,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
|
||||
{/if}
|
||||
{#if showVarPicker && customUi?.variable != false}
|
||||
<Button
|
||||
aiId="editor-bar-add-variable"
|
||||
aiDescription="Add variable"
|
||||
title="Add variable"
|
||||
color="light"
|
||||
btnClasses="!font-medium text-tertiary"
|
||||
@@ -622,6 +626,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
|
||||
|
||||
{#if showResourcePicker && customUi?.resource != false}
|
||||
<Button
|
||||
aiId="editor-bar-add-resource"
|
||||
aiDescription="Add resource"
|
||||
title="Add resource"
|
||||
btnClasses="!font-medium text-tertiary"
|
||||
size="xs"
|
||||
@@ -637,6 +643,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
|
||||
|
||||
{#if showResourceTypePicker && customUi?.type != false}
|
||||
<Button
|
||||
aiId="editor-bar-add-resource-type"
|
||||
aiDescription="Add resource type"
|
||||
title="Add resource type"
|
||||
btnClasses="!font-medium text-tertiary"
|
||||
size="xs"
|
||||
@@ -652,6 +660,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
|
||||
|
||||
{#if customUi?.reset != false}
|
||||
<Button
|
||||
aiId="editor-bar-reset-content"
|
||||
aiDescription="Reset content"
|
||||
title="Reset Content"
|
||||
btnClasses="!font-medium text-tertiary"
|
||||
size="xs"
|
||||
@@ -668,6 +678,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
|
||||
{#if customUi?.assistants != false}
|
||||
{#if lang == 'deno' || lang == 'python3' || lang == 'go' || lang == 'bash' || lang == 'nu'}
|
||||
<Button
|
||||
aiId="editor-bar-reload-assistants"
|
||||
aiDescription="Reload assistants"
|
||||
btnClasses="!font-medium text-tertiary"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
} from './triggers/utils'
|
||||
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
||||
import { Triggers } from './triggers/triggers.svelte'
|
||||
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
|
||||
|
||||
export let initialPath: string = ''
|
||||
export let pathStoreInit: string | undefined = undefined
|
||||
@@ -931,8 +932,8 @@
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !disableAi && customUi?.topBar?.aiBuilder != false && flowEditor?.getIsAiPanelClosed()}
|
||||
<FlowAIButton openPanel={() => flowEditor?.toggleAiPanel()} />
|
||||
{#if !disableAi && customUi?.topBar?.aiBuilder != false && !aiChatManager.open}
|
||||
<FlowAIButton openPanel={() => aiChatManager.openChat()} />
|
||||
{/if}
|
||||
<FlowPreviewButtons
|
||||
on:openTriggers={(e) => {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
type Script,
|
||||
type TriggersCount,
|
||||
PostgresTriggerService,
|
||||
CaptureService
|
||||
CaptureService,
|
||||
type ScriptLang
|
||||
} from '$lib/gen'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { initialCode } from '$lib/script_helpers'
|
||||
@@ -869,6 +870,47 @@
|
||||
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>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
@@ -898,18 +940,28 @@
|
||||
bind:open={metadataOpen}
|
||||
size={selectedTab === 'ui' || selectedTab === 'triggers' ? '1200px' : '800px'}
|
||||
>
|
||||
<DrawerContent noPadding title="Settings" on:close={() => (metadataOpen = false)}>
|
||||
<DrawerContent
|
||||
noPadding
|
||||
title="Settings"
|
||||
on:close={() => (metadataOpen = false)}
|
||||
aiId="script-builder-settings"
|
||||
aiDescription="Script builder settings"
|
||||
>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs bind:selected={selectedTab} wrapperClass="flex-none w-full">
|
||||
{#if customUi?.settingsPanel?.disableMetadata !== true}
|
||||
<Tab value="metadata">Metadata</Tab>
|
||||
<Tab value="metadata" aiId="script-builder-metadata" aiDescription="Metadata settings">
|
||||
Metadata
|
||||
</Tab>
|
||||
{/if}
|
||||
{#if customUi?.settingsPanel?.disableRuntime !== true}
|
||||
<Tab value="runtime">Runtime</Tab>
|
||||
<Tab value="runtime" aiId="script-builder-runtime" aiDescription="Runtime settings">
|
||||
Runtime
|
||||
</Tab>
|
||||
{/if}
|
||||
{#if customUi?.settingsPanel?.disableGeneratedUi !== true}
|
||||
<Tab value="ui">
|
||||
<Tab value="ui" aiId="script-builder-ui" aiDescription="Generated UI settings">
|
||||
Generated UI
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/json_schema_and_parsing"
|
||||
@@ -920,7 +972,7 @@
|
||||
</Tab>
|
||||
{/if}
|
||||
{#if customUi?.settingsPanel?.disableTriggers !== true}
|
||||
<Tab value="triggers">
|
||||
<Tab value="triggers" aiId="script-builder-triggers" aiDescription="Triggers settings">
|
||||
Triggers
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/getting_started/triggers">
|
||||
Configure how this script will be triggered.
|
||||
@@ -948,23 +1000,15 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<Label label="Summary">
|
||||
<MetadataGen
|
||||
aiId="create-script-summary-input"
|
||||
aiDescription="Summary / Title of the new script"
|
||||
label="Summary"
|
||||
bind:content={script.summary}
|
||||
lang={script.language}
|
||||
code={script.content}
|
||||
promptConfigName="summary"
|
||||
generateOnAppear
|
||||
on:change={() => {
|
||||
if (initialPath == '' && script.summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
script.summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
on:change={() => onSummaryChange(script.summary)}
|
||||
elementProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Short summary to be displayed when listed'
|
||||
@@ -1023,43 +1067,15 @@
|
||||
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
|
||||
>
|
||||
<Button
|
||||
aiId={`create-script-language-button-${lang}`}
|
||||
aiDescription={`Choose ${lang} as the language of the script`}
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={isPicked ? 'blue' : 'light'}
|
||||
btnClasses={isPicked
|
||||
? '!border-2 !bg-blue-50/75 dark:!bg-frost-900/75'
|
||||
: '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
|
||||
}}
|
||||
on:click={() => onScriptLanguageTrigger(lang)}
|
||||
disabled={lockedLanguage ||
|
||||
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
|
||||
>
|
||||
@@ -1628,6 +1644,8 @@
|
||||
<div class="flex flex-row gap-x-1 lg:gap-x-2">
|
||||
{#if customUi?.topBar?.settings != false}
|
||||
<Button
|
||||
aiId="script-builder-settings"
|
||||
aiDescription="Script builder settings to configure metadata, runtime, triggers, and generated UI."
|
||||
color="light"
|
||||
variant="border"
|
||||
size="xs"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { BROWSER } from 'esm-env'
|
||||
|
||||
import type { Schema, SupportedLanguage } from '$lib/common'
|
||||
import { type CompletedJob, type Job, JobService, type Preview } from '$lib/gen'
|
||||
import { type CompletedJob, type Job, JobService, type Preview, type ScriptLang } from '$lib/gen'
|
||||
import { copilotInfo, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils'
|
||||
import Editor from './Editor.svelte'
|
||||
@@ -38,13 +38,15 @@
|
||||
import { slide } from 'svelte/transition'
|
||||
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
|
||||
import CaptureButton from './triggers/CaptureButton.svelte'
|
||||
import AIChat from './copilot/chat/AIChat.svelte'
|
||||
import { setContext } from 'svelte'
|
||||
import HideButton from './apps/editor/settingsPanel/HideButton.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/script/core'
|
||||
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
|
||||
import { getStringError } from './copilot/chat/utils'
|
||||
import type { ScriptOptions } from './copilot/chat/ContextManager.svelte'
|
||||
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
|
||||
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||
|
||||
// Exported
|
||||
export let schema: Schema | any = emptySchema()
|
||||
@@ -127,9 +129,6 @@
|
||||
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') {
|
||||
event.preventDefault()
|
||||
runTest()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key == 'l') {
|
||||
event.preventDefault()
|
||||
toggleAiPanel()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key == 'u') {
|
||||
event.preventDefault()
|
||||
toggleTestPanel()
|
||||
@@ -203,6 +202,7 @@
|
||||
onMount(() => {
|
||||
inferSchema(code)
|
||||
loadPastTests()
|
||||
aiChatManager.changeMode('script')
|
||||
})
|
||||
|
||||
setLicense()
|
||||
@@ -276,6 +276,10 @@
|
||||
|
||||
onDestroy(() => {
|
||||
disableCollaboration()
|
||||
aiChatManager.scriptEditorApplyCode = undefined
|
||||
aiChatManager.scriptEditorShowDiffMode = undefined
|
||||
aiChatManager.scriptEditorOptions = undefined
|
||||
aiChatManager.changeMode('navigator')
|
||||
})
|
||||
|
||||
function asKind(str: string | undefined) {
|
||||
@@ -304,44 +308,23 @@
|
||||
|
||||
setContext('disableTooltips', customUi?.disableTooltips === true)
|
||||
|
||||
let aiPanelSize =
|
||||
!$copilotInfo.enabled ||
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '') ||
|
||||
localStorage.getItem('aiPanelOpen') === 'false'
|
||||
? 0
|
||||
: 30
|
||||
let codePanelSize = 40 + (30 - aiPanelSize)
|
||||
let storedAiPanelSize = aiPanelSize > 0 ? aiPanelSize : 30
|
||||
let codePanelSize = 70
|
||||
let testPanelSize = 30
|
||||
let storedTestPanelSize = testPanelSize
|
||||
|
||||
function toggleAiPanel() {
|
||||
if (!$copilotInfo.enabled) return
|
||||
if (aiPanelSize > 0) {
|
||||
storedAiPanelSize = aiPanelSize
|
||||
codePanelSize += aiPanelSize
|
||||
aiPanelSize = 0
|
||||
localStorage.setItem('aiPanelOpen', 'false')
|
||||
} else {
|
||||
codePanelSize -= storedAiPanelSize
|
||||
aiPanelSize = storedAiPanelSize
|
||||
localStorage.setItem('aiPanelOpen', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
function addSelectedLinesToAiChat(
|
||||
e: CustomEvent<{ lines: string; startLine: number; endLine: number }>
|
||||
) {
|
||||
if (aiChat) {
|
||||
aiChat.addSelectedLinesToContext(e.detail.lines, e.detail.startLine, e.detail.endLine)
|
||||
if (aiPanelSize === 0) {
|
||||
toggleAiPanel()
|
||||
}
|
||||
aiChat.focusTextArea()
|
||||
if (!aiChatManager.open) {
|
||||
aiChatManager.toggleOpen()
|
||||
}
|
||||
aiChatManager.addSelectedLinesToContext(e.detail.lines, e.detail.startLine, e.detail.endLine)
|
||||
// aiChatManager.focusTextArea() TODO: Add this back
|
||||
}
|
||||
|
||||
$: !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '') && aiPanelSize > 0 && toggleAiPanel()
|
||||
$: !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '') &&
|
||||
!aiChatManager.open &&
|
||||
aiChatManager.toggleOpen()
|
||||
|
||||
function toggleTestPanel() {
|
||||
if (testPanelSize > 0) {
|
||||
@@ -354,8 +337,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let aiChat: AIChat | undefined = undefined
|
||||
|
||||
function getError(job: Job | undefined) {
|
||||
if (job != undefined && job.type === 'CompletedJob' && !job.success) {
|
||||
return getStringError(job.result)
|
||||
@@ -378,6 +359,25 @@
|
||||
}
|
||||
|
||||
$: error = getError(testJob)
|
||||
|
||||
$: {
|
||||
const options: ScriptOptions = {
|
||||
code,
|
||||
lang: lang as ScriptLang,
|
||||
error,
|
||||
args,
|
||||
path,
|
||||
lastSavedCode,
|
||||
lastDeployedCode,
|
||||
diffMode
|
||||
}
|
||||
aiChatManager.scriptEditorOptions = options
|
||||
aiChatManager.scriptEditorApplyCode = (code: string) => {
|
||||
hideDiffMode()
|
||||
editor?.reviewAndApplyCode(code)
|
||||
}
|
||||
aiChatManager.scriptEditorShowDiffMode = showDiffMode
|
||||
}
|
||||
</script>
|
||||
|
||||
<TestJobLoader
|
||||
@@ -390,6 +390,8 @@
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
|
||||
<TriggerableByAI id="script-editor" description="Component to edit a script" />
|
||||
|
||||
<Modal title="Invite others" bind:open={showCollabPopup}>
|
||||
<div>Have others join by sharing the following url:</div>
|
||||
<div class="flex gap-2 pr-4">
|
||||
@@ -464,7 +466,22 @@
|
||||
<Pane bind:size={codePanelSize} minSize={10} class="!overflow-visible">
|
||||
<div class="h-full !overflow-visible bg-gray-50 dark:bg-[#272D38] relative">
|
||||
<div class="absolute top-2 right-4 z-10 flex flex-row gap-2">
|
||||
{#if aiPanelSize === 0}
|
||||
{#if testPanelSize === 0}
|
||||
<HideButton
|
||||
hidden={true}
|
||||
direction="right"
|
||||
size="md"
|
||||
panelName="Test"
|
||||
shortcut="U"
|
||||
customHiddenIcon={PlayIcon}
|
||||
on:click={() => {
|
||||
toggleTestPanel()
|
||||
}}
|
||||
btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70"
|
||||
color="marine"
|
||||
/>
|
||||
{/if}
|
||||
{#if !aiChatManager.open}
|
||||
{#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')}
|
||||
<HideButton
|
||||
hidden={true}
|
||||
@@ -476,7 +493,7 @@
|
||||
customHiddenIcon={WandSparkles}
|
||||
btnClasses="!text-violet-800 dark:!text-violet-400 border border-gray-200 dark:border-gray-600 bg-surface"
|
||||
on:click={() => {
|
||||
toggleAiPanel()
|
||||
aiChatManager.toggleOpen()
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="popoverOverride">
|
||||
@@ -492,21 +509,6 @@
|
||||
</svelte:fragment>
|
||||
</HideButton>
|
||||
{/if}
|
||||
{#if testPanelSize === 0}
|
||||
<HideButton
|
||||
hidden={true}
|
||||
direction="right"
|
||||
size="md"
|
||||
panelName="Test"
|
||||
shortcut="U"
|
||||
customHiddenIcon={PlayIcon}
|
||||
on:click={() => {
|
||||
toggleTestPanel()
|
||||
}}
|
||||
btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70"
|
||||
color="marine"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#key lang}
|
||||
@@ -523,7 +525,7 @@
|
||||
inferSchema(e.detail)
|
||||
}}
|
||||
on:saveDraft
|
||||
on:toggleAiPanel={toggleAiPanel}
|
||||
on:toggleAiPanel={() => aiChatManager.toggleOpen()}
|
||||
on:addSelectedLinesToAiChat={addSelectedLinesToAiChat}
|
||||
on:toggleTestPanel={toggleTestPanel}
|
||||
cmdEnterAction={async () => {
|
||||
@@ -560,62 +562,6 @@
|
||||
{/key}
|
||||
</div>
|
||||
</Pane>
|
||||
{#if lang && $copilotInfo.enabled}
|
||||
{#snippet aiChatHeaderRight()}
|
||||
{#if testPanelSize === 0}
|
||||
<div class="bg-gray-200 h-6 w-[1px] rounded-full dark:bg-gray-600"></div>
|
||||
<HideButton
|
||||
hidden={true}
|
||||
direction="right"
|
||||
panelName="Test"
|
||||
shortcut="U"
|
||||
size="md"
|
||||
customHiddenIcon={PlayIcon}
|
||||
on:click={() => {
|
||||
toggleTestPanel()
|
||||
}}
|
||||
btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70"
|
||||
color="marine"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet aiChatHeaderLeft()}
|
||||
<HideButton
|
||||
hidden={false}
|
||||
direction="right"
|
||||
panelName="AI"
|
||||
shortcut="L"
|
||||
size="md"
|
||||
on:click={() => {
|
||||
toggleAiPanel()
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if aiPanelSize > 0}
|
||||
<Pane bind:size={aiPanelSize} minSize={0}>
|
||||
<AIChat
|
||||
bind:this={aiChat}
|
||||
scriptOptions={{
|
||||
code,
|
||||
lang,
|
||||
error,
|
||||
args,
|
||||
path,
|
||||
lastSavedCode,
|
||||
lastDeployedCode,
|
||||
diffMode
|
||||
}}
|
||||
applyCode={(code) => {
|
||||
hideDiffMode()
|
||||
editor?.reviewAndApplyCode(code)
|
||||
}}
|
||||
{showDiffMode}
|
||||
headerLeft={aiChatHeaderLeft}
|
||||
headerRight={aiChatHeaderRight}
|
||||
/>
|
||||
</Pane>
|
||||
{/if}
|
||||
{/if}
|
||||
<Pane bind:size={testPanelSize} minSize={0}>
|
||||
<div class="flex flex-col h-full">
|
||||
{#if showTabs}
|
||||
@@ -729,7 +675,7 @@
|
||||
<LogPanel
|
||||
bind:setFocusToLogs
|
||||
on:fix={() => {
|
||||
aiChat?.fix()
|
||||
aiChatManager.fix()
|
||||
}}
|
||||
fixChatMode
|
||||
{lang}
|
||||
|
||||
@@ -133,9 +133,17 @@
|
||||
</div>
|
||||
<div class="pt-4 h-full">
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab value="users">Users</Tab>
|
||||
<Tab value="users" aiId="instance-settings-users" aiDescription="Instance users settings"
|
||||
>Users</Tab
|
||||
>
|
||||
{#each settingsKeys as category}
|
||||
<Tab value={category}>{category}</Tab>
|
||||
<Tab
|
||||
value={category}
|
||||
aiId={`instance-settings-${category}`}
|
||||
aiDescription={`Instance ${category} settings`}
|
||||
>
|
||||
{category}
|
||||
</Tab>
|
||||
{/each}
|
||||
<svelte:fragment slot="content">
|
||||
<div class="pt-4"></div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
|
||||
let { id, description, onTrigger, children } = $props<{
|
||||
id: string | undefined
|
||||
description: string | undefined
|
||||
onTrigger?: (value?: string) => void // Function to call when the trigger is activated, if not provided, the component is discoverable for information purposes only
|
||||
children?: () => any
|
||||
}>()
|
||||
|
||||
let isAnimating = $state(false)
|
||||
|
||||
// Component is not discoverable if id or description is not provided
|
||||
const disabled = !id || !description
|
||||
|
||||
function handleTrigger(value?: string) {
|
||||
if (disabled || !onTrigger) return
|
||||
isAnimating = true
|
||||
onTrigger(value)
|
||||
setTimeout(() => {
|
||||
isAnimating = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (disabled) return
|
||||
|
||||
// register the triggerable
|
||||
const currentId = id
|
||||
const currentData = { description, onTrigger: handleTrigger }
|
||||
const existingTriggerables = aiChatManager.triggerablesByAI
|
||||
existingTriggerables[currentId] = currentData
|
||||
|
||||
return () => {
|
||||
// unregister the triggerable
|
||||
if (aiChatManager.triggerablesByAI[currentId]) {
|
||||
delete aiChatManager.triggerablesByAI[currentId]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<div class="relative">
|
||||
{#if isAnimating}
|
||||
<div
|
||||
class="absolute -top-2.5 left-1/2 -translate-x-1/2 w-10 h-10 bg-blue-500/90 rounded-full z-[9999] pointer-events-none animate-ping"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="contents h-full">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -9,7 +9,11 @@
|
||||
import type { Placement } from '@floating-ui/core'
|
||||
import { conditionalMelt } from '$lib/utils'
|
||||
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
|
||||
export let id: string = ''
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let size: ButtonType.Size = 'md'
|
||||
export let spacingSize: ButtonType.Size = size
|
||||
export let color: ButtonType.Color | string = 'blue'
|
||||
@@ -26,7 +30,6 @@
|
||||
export let clickableWhileLoading = false
|
||||
|
||||
export let element: ButtonType.Element | undefined = undefined
|
||||
export let id: string = ''
|
||||
export let nonCaptureEvent: boolean = false
|
||||
export let propagateEvent: boolean = false
|
||||
export let loading = false
|
||||
@@ -72,6 +75,10 @@
|
||||
element?.focus({})
|
||||
}
|
||||
|
||||
export function click() {
|
||||
element?.click()
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
|
||||
// Order of classes: border, border modifier, bg, bg modifier, text, text modifier, everything else
|
||||
@@ -155,150 +162,160 @@
|
||||
$: $open !== undefined && dispatchIfMounted('tooltipOpen', $open)
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
dropdownItems && dropdownItems.length > 0 && variant === 'contained'
|
||||
? ButtonType.ColorVariants[color].divider
|
||||
: '',
|
||||
wrapperClasses,
|
||||
'flex flex-row',
|
||||
disabled ? 'divide-text-disabled' : ''
|
||||
)}
|
||||
style={wrapperStyle}
|
||||
data-interactive
|
||||
<TriggerableByAI
|
||||
id={aiId}
|
||||
description={aiDescription}
|
||||
onTrigger={() => {
|
||||
element?.click()
|
||||
}}
|
||||
>
|
||||
{#if href && !disabled}
|
||||
<a
|
||||
bind:this={element}
|
||||
on:pointerdown
|
||||
on:focus
|
||||
on:blur
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:click={() => {
|
||||
loading = true
|
||||
dispatch('click', event)
|
||||
if (!loadUntilNav) {
|
||||
loading = false
|
||||
}
|
||||
}}
|
||||
{href}
|
||||
{download}
|
||||
class={buttonClass}
|
||||
{id}
|
||||
{target}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
{...$$restProps}
|
||||
{style}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 class={twMerge('animate-spin', iconOnlyPadding[size])} size={lucideIconSize} />
|
||||
{:else if startIcon?.icon}
|
||||
<svelte:component
|
||||
this={startIcon.icon}
|
||||
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
{...startIcon.props}
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class={twMerge(
|
||||
dropdownItems && dropdownItems.length > 0 && variant === 'contained'
|
||||
? ButtonType.ColorVariants[color].divider
|
||||
: '',
|
||||
wrapperClasses,
|
||||
'flex flex-row',
|
||||
disabled ? 'divide-text-disabled' : ''
|
||||
)}
|
||||
style={wrapperStyle}
|
||||
data-interactive
|
||||
>
|
||||
{#if href && !disabled}
|
||||
<a
|
||||
bind:this={element}
|
||||
on:pointerdown
|
||||
on:focus
|
||||
on:blur
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:click={() => {
|
||||
loading = true
|
||||
dispatch('click', event)
|
||||
if (!loadUntilNav) {
|
||||
loading = false
|
||||
}
|
||||
}}
|
||||
{href}
|
||||
{download}
|
||||
class={buttonClass}
|
||||
{id}
|
||||
{target}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
{...$$restProps}
|
||||
{style}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 class={twMerge('animate-spin', iconOnlyPadding[size])} size={lucideIconSize} />
|
||||
{:else if startIcon?.icon}
|
||||
<svelte:component
|
||||
this={startIcon.icon}
|
||||
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
{...startIcon.props}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !iconOnly}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if endIcon?.icon}
|
||||
<svelte:component
|
||||
this={endIcon.icon}
|
||||
class={twMerge(endIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
/>
|
||||
{/if}
|
||||
{#if shortCut && !shortCut.hide}
|
||||
<div class="flex flex-row items-center !text-md opacity-60 gap-0 font-normal">
|
||||
{#if shortCut.withoutModifier !== true}{getModifierKey()}{/if}{#if shortCut.Icon}<shortCut.Icon
|
||||
class="w-4 h-4"
|
||||
size={lucideIconSize}
|
||||
/>{:else}{shortCut.key}{/if}
|
||||
{#if !iconOnly}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if endIcon?.icon}
|
||||
<svelte:component
|
||||
this={endIcon.icon}
|
||||
class={twMerge(endIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
/>
|
||||
{/if}
|
||||
{#if shortCut && !shortCut.hide}
|
||||
<div class="flex flex-row items-center !text-md opacity-60 gap-0 font-normal">
|
||||
{#if shortCut.withoutModifier !== true}{getModifierKey()}{/if}{#if shortCut.Icon}<shortCut.Icon
|
||||
class="w-4 h-4"
|
||||
size={lucideIconSize}
|
||||
/>{:else}{shortCut.key}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={element}
|
||||
on:pointerdown
|
||||
on:click={onClick}
|
||||
on:focus
|
||||
on:blur
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
class={buttonClass}
|
||||
{id}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
{title}
|
||||
{...$$restProps}
|
||||
disabled={disabled || (loading && !clickableWhileLoading)}
|
||||
{style}
|
||||
use:conditionalMelt={trigger}
|
||||
{...$trigger}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 class={twMerge('animate-spin', iconOnlyPadding[size])} size={lucideIconSize} />
|
||||
{:else if startIcon?.icon}
|
||||
<svelte:component
|
||||
this={startIcon.icon}
|
||||
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
{...startIcon.props}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !iconOnly}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if endIcon?.icon}
|
||||
<svelte:component
|
||||
this={endIcon.icon}
|
||||
class={twMerge(endIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
/>
|
||||
{/if}
|
||||
{#if shortCut && !shortCut.hide}
|
||||
{@const Icon = shortCut.Icon}
|
||||
<div class="flex flex-row items-center !text-md opacity-60 gap-0 font-normal">
|
||||
{#if shortCut.withoutModifier !== true}{getModifierKey()}{/if}{#if shortCut.Icon}<Icon
|
||||
size={lucideIconSize}
|
||||
/>{:else}{shortCut.key}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if tooltipPopover && $open}
|
||||
<div use:conditionalMelt={content} {...$content} class="z-[20000]">
|
||||
<slot name="tooltip" />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={element}
|
||||
on:pointerdown
|
||||
on:click={onClick}
|
||||
on:focus
|
||||
on:blur
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
class={buttonClass}
|
||||
{id}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
{title}
|
||||
{...$$restProps}
|
||||
disabled={disabled || (loading && !clickableWhileLoading)}
|
||||
{style}
|
||||
use:conditionalMelt={trigger}
|
||||
{...$trigger}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 class={twMerge('animate-spin', iconOnlyPadding[size])} size={lucideIconSize} />
|
||||
{:else if startIcon?.icon}
|
||||
<svelte:component
|
||||
this={startIcon.icon}
|
||||
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
{...startIcon.props}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !iconOnly}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if endIcon?.icon}
|
||||
<svelte:component
|
||||
this={endIcon.icon}
|
||||
class={twMerge(endIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
/>
|
||||
{/if}
|
||||
{#if shortCut && !shortCut.hide}
|
||||
{@const Icon = shortCut.Icon}
|
||||
<div class="flex flex-row items-center !text-md opacity-60 gap-0 font-normal">
|
||||
{#if shortCut.withoutModifier !== true}{getModifierKey()}{/if}{#if shortCut.Icon}<Icon
|
||||
size={lucideIconSize}
|
||||
/>{:else}{shortCut.key}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if tooltipPopover && $open}
|
||||
<div use:conditionalMelt={content} {...$content} class="z-[20000]">
|
||||
<slot name="tooltip" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if dropdownItems && dropdownItems.length > 0}
|
||||
<Dropdown
|
||||
items={computeDropdowns(dropdownItems)}
|
||||
class="h-auto w-fit"
|
||||
hidePopup={hideDropdown}
|
||||
usePointerDownOutside
|
||||
on:open={() => dispatch('dropdownOpen', true)}
|
||||
on:close={() => dispatch('dropdownOpen', false)}
|
||||
>
|
||||
<svelte:fragment slot="buttonReplacement">
|
||||
<div
|
||||
class={twMerge(
|
||||
buttonClass,
|
||||
'rounded-md m-0 p-0 center-center h-full',
|
||||
variant === 'border' ? 'border-0 border-r border-y ' : 'border-0',
|
||||
'rounded-r-md !rounded-l-none',
|
||||
size === 'xs2' ? '!w-8' : '!w-10'
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={lucideIconSize} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dropdownItems && dropdownItems.length > 0}
|
||||
<Dropdown
|
||||
aiId={aiId ? `${aiId}-dropdown` : undefined}
|
||||
aiDescription={aiDescription ? `${aiDescription} dropdown` : undefined}
|
||||
items={computeDropdowns(dropdownItems)}
|
||||
class="h-full w-fit"
|
||||
hidePopup={hideDropdown}
|
||||
usePointerDownOutside
|
||||
on:open={() => dispatch('dropdownOpen', true)}
|
||||
on:close={() => dispatch('dropdownOpen', false)}
|
||||
>
|
||||
<svelte:fragment slot="buttonReplacement">
|
||||
<div
|
||||
class={twMerge(
|
||||
buttonClass,
|
||||
'rounded-md m-0 p-0 center-center h-full',
|
||||
variant === 'border' ? 'border-0 border-r border-y ' : 'border-0',
|
||||
'rounded-r-md !rounded-l-none',
|
||||
size === 'xs2' ? '!w-8' : '!w-10'
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={lucideIconSize} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
</TriggerableByAI>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
} from '$lib/utils'
|
||||
import { page } from '$app/stores'
|
||||
import type { GetInitialAndModifiedValues } from './unsavedTypes'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
|
||||
export let getInitialAndModifiedValues: GetInitialAndModifiedValues = undefined
|
||||
export let diffDrawer: DiffDrawer | undefined = undefined
|
||||
@@ -67,6 +68,13 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<TriggerableByAI
|
||||
id="unsaved-changes-confirmation-modal"
|
||||
description="Unsaved changes confirmation modal. Needs user confirmation to leave the page."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ConfirmationModal
|
||||
{open}
|
||||
title="Unsaved changes detected"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { BROWSER } from 'esm-env'
|
||||
import Disposable from './Disposable.svelte'
|
||||
import ConditionalPortal from './ConditionalPortal.svelte'
|
||||
import { aiChatManager } from '../../copilot/chat/AIChatManager.svelte'
|
||||
|
||||
export let open = false
|
||||
export let duration = 0.3
|
||||
@@ -76,10 +77,11 @@
|
||||
>
|
||||
<aside
|
||||
class="drawer windmill-app windmill-drawer {$$props.class ?? ''} {$$props.positionClass ??
|
||||
''}"
|
||||
''} {aiChatManager.open ? 'respect-global-chat' : ''}"
|
||||
class:open
|
||||
class:close={!open && timeout}
|
||||
style={`${style}; --zIndex: ${zIndex};`}
|
||||
class:global-chat-open={aiChatManager.open}
|
||||
style={`${style}; --zIndex: ${zIndex}; --adjusted-offset: ${aiChatManager.open && placement === 'right' ? aiChatManager.size : 0}%`}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
@@ -108,8 +110,9 @@
|
||||
|
||||
.drawer.open {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: var(--zIndex);
|
||||
right: 0;
|
||||
width: calc(100% - var(--adjusted-offset));
|
||||
transition: z-index var(--duration) step-start;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -126,6 +129,12 @@
|
||||
transition: opacity var(--duration) ease;
|
||||
}
|
||||
|
||||
.drawer.respect-global-chat.global-chat-open > .overlay {
|
||||
width: 100%;
|
||||
right: var(--adjusted-offset);
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.drawer.open > .overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -140,7 +149,9 @@
|
||||
width: 100%;
|
||||
@apply bg-surface;
|
||||
z-index: 3;
|
||||
transition: transform var(--duration) ease, max-width var(--duration) ease,
|
||||
transition:
|
||||
transform var(--duration) ease,
|
||||
max-width var(--duration) ease,
|
||||
max-height var(--duration) ease;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -155,6 +166,11 @@
|
||||
transform: translate(100%, 0);
|
||||
}
|
||||
|
||||
.drawer.respect-global-chat.global-chat-open > .panel.right {
|
||||
right: var(--adjusted-offset);
|
||||
width: calc(100vw - var(--adjusted-offset));
|
||||
}
|
||||
|
||||
.panel.top {
|
||||
top: 0;
|
||||
transform: translate(0, -100%);
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { classNames } from '$lib/utils'
|
||||
import CloseButton from '../CloseButton.svelte'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let title: string | undefined = undefined
|
||||
export let overflow_y = true
|
||||
export let noPadding = false
|
||||
@@ -12,13 +16,22 @@
|
||||
export let CloseIcon: any | undefined = undefined
|
||||
|
||||
export let fullScreen: boolean = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<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 items-center gap-2 w-full truncate">
|
||||
<CloseButton on:close Icon={CloseIcon} />
|
||||
|
||||
<TriggerableByAI
|
||||
id={`close-${aiId}`}
|
||||
description={`Close ${aiDescription}`}
|
||||
onTrigger={() => {
|
||||
dispatch('close')
|
||||
}}
|
||||
>
|
||||
<CloseButton on:close Icon={CloseIcon} />
|
||||
</TriggerableByAI>
|
||||
<span class="font-semibold truncate text-primary !text-lg max-w-sm"
|
||||
>{title ?? ''}
|
||||
{#if tooltip != '' || documentationLink}
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
{#if app.canWrite}
|
||||
<div>
|
||||
<Button
|
||||
aiId={`edit-app-button-${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
aiDescription={`Edits the app ${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
@@ -111,6 +113,8 @@
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
aiId={`fork-app-button-${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
aiDescription={`Fork the app ${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
@@ -124,6 +128,8 @@
|
||||
{/if}
|
||||
</span>
|
||||
<Dropdown
|
||||
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
|
||||
items={async () => {
|
||||
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
|
||||
|
||||
@@ -184,7 +190,7 @@
|
||||
},
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON',
|
||||
@@ -231,7 +237,7 @@
|
||||
gotoUrl(url)
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
...(has_draft
|
||||
? [
|
||||
@@ -250,7 +256,7 @@
|
||||
disabled: !canWrite,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
|
||||
@@ -114,6 +114,8 @@
|
||||
variant="border"
|
||||
startIcon={{ icon: Pen }}
|
||||
href="{base}/flows/edit/{flow.path}?nodraft=true"
|
||||
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -126,6 +128,8 @@
|
||||
variant="border"
|
||||
startIcon={{ icon: GitFork }}
|
||||
href="{base}/flows/add?template={flow.path}"
|
||||
aiId={`fork-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
aiDescription={`Fork the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
@@ -135,6 +139,8 @@
|
||||
</span>
|
||||
|
||||
<Dropdown
|
||||
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
|
||||
items={async () => {
|
||||
let { draft_only, path, archived, has_draft } = flow
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
|
||||
@@ -140,6 +140,8 @@
|
||||
{:else if script.canWrite && !script.archived}
|
||||
<div>
|
||||
<Button
|
||||
aiId={`edit-script-button-${script.summary?.length > 0 ? script.summary : script.path}`}
|
||||
aiDescription={`Edits the script ${script.summary?.length > 0 ? script.summary : script.path}`}
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
@@ -152,6 +154,8 @@
|
||||
{:else if !script.draft_only}
|
||||
<div>
|
||||
<Button
|
||||
aiId={`fork-script-button-${script.summary ?? script.path}`}
|
||||
aiDescription={`Fork the script ${script.summary ?? script.path}`}
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
@@ -165,6 +169,8 @@
|
||||
{/if}
|
||||
</span>
|
||||
<Dropdown
|
||||
aiId={`script-row-dropdown-${script.summary?.length > 0 ? script.summary : script.path}`}
|
||||
aiDescription={`Open dropdown for script ${script.summary?.length > 0 ? script.summary : script.path} options`}
|
||||
items={async () => {
|
||||
let owner = isOwner(script.path, $userStore, $workspaceStore)
|
||||
if (script.draft_only) {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { getContext } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { TabsContext } from './Tabs.svelte'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let value: string
|
||||
export let size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'sm'
|
||||
let c = ''
|
||||
@@ -41,30 +44,42 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={twMerge(
|
||||
'border-b-2 py-1 px-2 cursor-pointer transition-all z-10 ease-linear font-normal text-tertiary',
|
||||
isSelected
|
||||
? 'wm-tab-active font-main'
|
||||
: 'border-gray-300 dark:border-gray-600 border-opacity-0 hover:border-opacity-100 ',
|
||||
fontSizeClasses[size],
|
||||
c,
|
||||
isSelected ? selectedClass : '',
|
||||
disabled ? 'cursor-not-allowed text-tertiary' : ''
|
||||
)}
|
||||
style={`${style} ${isSelected ? selectedStyle : ''}`}
|
||||
on:click={() => {
|
||||
<TriggerableByAI
|
||||
id={aiId}
|
||||
description={aiDescription}
|
||||
onTrigger={() => {
|
||||
if (hashNavigation) {
|
||||
window.location.hash = value
|
||||
} else {
|
||||
update(value)
|
||||
}
|
||||
}}
|
||||
on:pointerdown|stopPropagation
|
||||
{disabled}
|
||||
{id}
|
||||
>
|
||||
<div class={twMerge(active ? 'bg-blue-50 text-blue-800 rounded-md ' : '', 'px-2 ')}>
|
||||
<slot />
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class={twMerge(
|
||||
'border-b-2 py-1 px-2 cursor-pointer transition-all z-10 ease-linear font-normal text-tertiary',
|
||||
isSelected
|
||||
? 'wm-tab-active font-main'
|
||||
: 'border-gray-300 dark:border-gray-600 border-opacity-0 hover:border-opacity-100 ',
|
||||
fontSizeClasses[size],
|
||||
c,
|
||||
isSelected ? selectedClass : '',
|
||||
disabled ? 'cursor-not-allowed text-tertiary' : ''
|
||||
)}
|
||||
style={`${style} ${isSelected ? selectedStyle : ''}`}
|
||||
on:click={() => {
|
||||
if (hashNavigation) {
|
||||
window.location.hash = value
|
||||
} else {
|
||||
update(value)
|
||||
}
|
||||
}}
|
||||
on:pointerdown|stopPropagation
|
||||
{disabled}
|
||||
{id}
|
||||
>
|
||||
<div class={twMerge(active ? 'bg-blue-50 text-blue-800 rounded-md ' : '', 'px-2 ')}>
|
||||
<slot />
|
||||
</div>
|
||||
</button>
|
||||
</TriggerableByAI>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { WandSparkles } from 'lucide-svelte'
|
||||
import { aiChatManager } from './chat/AIChatManager.svelte'
|
||||
interface Props {
|
||||
label?: string
|
||||
initialInput?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const { label, initialInput, onClick: onClickProp }: Props = $props()
|
||||
|
||||
export function onClick() {
|
||||
aiChatManager.openChat()
|
||||
if (initialInput) {
|
||||
aiChatManager.askAi(initialInput, {
|
||||
withCode: false,
|
||||
withDiff: false
|
||||
})
|
||||
}
|
||||
onClickProp?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
iconOnly={!label}
|
||||
startIcon={{
|
||||
icon: WandSparkles
|
||||
}}
|
||||
size="xs2"
|
||||
btnClasses="!text-violet-800 dark:!text-violet-400 border border-gray-200 dark:border-gray-600 !bg-surface"
|
||||
on:click={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
@@ -22,7 +22,7 @@
|
||||
"You are a helpful assistant for creating CRON schedules using both standard and extended Croner patterns. The structure is 'second minute hour dayOfMonth month dayOfWeek'. Supported modifiers: ? (wildcard), L (last day/weekday), # (nth occurrence of a weekday), and W (closest weekday). Weekdays are Sunday (0 or 7), Monday (1), Tuesday (2), Wednesday (3), Thursday (4), Friday (5), Saturday (6). Ensure syntax is valid, including optional seconds and special modifiers. You only return either the CRON string without any leading/closing quotes or an error message prefixed with 'ERROR:'."
|
||||
|
||||
const SYSTEM_V1 =
|
||||
"You are a helpful assitant for creating CRON schedules. The structure is 'second minute hour dayOfMonth month dayOfWeek'. Weekdays are Sunday (1), Monday (2), Tuesday (3), Wednesday (4), Thursday (5), Friday (6), Saturday (7). You only return the CRON string without any wrapping characters. If it is invalid, you will return an error message preceeded by 'ERROR:'."
|
||||
"You are a helpful assistant for creating CRON schedules. The structure is 'second minute hour dayOfMonth month dayOfWeek'. Weekdays are Sunday (1), Monday (2), Tuesday (3), Wednesday (4), Thursday (5), Friday (6), Saturday (7). You only return the CRON string without any wrapping characters. If it is invalid, you will return an error message preceded by 'ERROR:'."
|
||||
|
||||
$: updateSystemPrompt(cronVersion)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { yamlStringifyExceptKeys } from './utils'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
|
||||
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
|
||||
type PromptConfig = {
|
||||
system: string
|
||||
@@ -78,6 +79,8 @@ Generate a description for the flow below:
|
||||
}
|
||||
}
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let content: string | undefined
|
||||
export let code: string | undefined = undefined
|
||||
export let flow: FlowValue | undefined = undefined
|
||||
@@ -187,104 +190,117 @@ Generate a description for the flow below:
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class={twMerge('relative', $$props.class)}
|
||||
bind:clientWidth={width}
|
||||
on:keydown={(event) => {
|
||||
if (!$copilotInfo.enabled || !$metadataCompletionEnabled) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
if (manualDisabled) {
|
||||
event.preventDefault()
|
||||
manualDisabled = false
|
||||
} else if (!loading && generatedContent) {
|
||||
event.preventDefault()
|
||||
content = generatedContent
|
||||
generatedContent = ''
|
||||
} else if (!loading && !content) {
|
||||
event.preventDefault()
|
||||
generateContent()
|
||||
}
|
||||
} else if (event.key === 'Escape' && !manualDisabled) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (loading) {
|
||||
abortController.abort()
|
||||
} else {
|
||||
manualDisabled = true
|
||||
generatedContent = ''
|
||||
}
|
||||
} else if (event.key === 'Backspace' && !loading && !content) {
|
||||
manualDisabled = true
|
||||
generatedContent = ''
|
||||
<TriggerableByAI
|
||||
id={aiId}
|
||||
description={aiDescription}
|
||||
onTrigger={(value) => {
|
||||
if (value) {
|
||||
content = value
|
||||
dispatchIfMounted('change', { content })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="absolute left-[0.5rem] {elementType === 'textarea'
|
||||
? 'top-[1.3rem]'
|
||||
: 'top-[0.3rem]'} flex flex-row gap-2 items-start pointer-events-none"
|
||||
class={twMerge('relative', $$props.class)}
|
||||
bind:clientWidth={width}
|
||||
on:keydown={(event) => {
|
||||
if (!$copilotInfo.enabled || !$metadataCompletionEnabled) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
if (manualDisabled) {
|
||||
event.preventDefault()
|
||||
manualDisabled = false
|
||||
} else if (!loading && generatedContent) {
|
||||
event.preventDefault()
|
||||
content = generatedContent
|
||||
generatedContent = ''
|
||||
} else if (!loading && !content) {
|
||||
event.preventDefault()
|
||||
generateContent()
|
||||
}
|
||||
} else if (event.key === 'Escape' && !manualDisabled) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (loading) {
|
||||
abortController.abort()
|
||||
} else {
|
||||
manualDisabled = true
|
||||
generatedContent = ''
|
||||
}
|
||||
} else if (event.key === 'Backspace' && !loading && !content) {
|
||||
manualDisabled = true
|
||||
generatedContent = ''
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if active}
|
||||
<span
|
||||
class={twMerge(
|
||||
'absolute text-xs bg-violet-100 text-violet-800 dark:bg-gray-700 dark:text-violet-400 px-1 py-0.5 rounded-md flex flex-row items-center justify-center gap-2 transition-all shrink-0',
|
||||
!loading && generatedContent.length > 0
|
||||
? 'bg-green-100 text-green-800 dark:text-green-400 dark:bg-green-700'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<span class="px-0.5 py-0.5 rounded-md text-2xs text-bold flex flex-row items-center gap-1">
|
||||
{#if loading}
|
||||
ESC
|
||||
{:else}
|
||||
TAB
|
||||
{/if}
|
||||
{#if loading}
|
||||
<Loader2 class="animate-spin" size={12} />
|
||||
{:else if generatedContent}
|
||||
<Check size={12} />
|
||||
{:else}
|
||||
<Wand2 size={12} />
|
||||
{/if}
|
||||
<div
|
||||
class="absolute left-[0.5rem] {elementType === 'textarea'
|
||||
? 'top-[1.3rem]'
|
||||
: 'top-[0.3rem]'} flex flex-row gap-2 items-start pointer-events-none"
|
||||
>
|
||||
{#if active}
|
||||
<span
|
||||
class={twMerge(
|
||||
'absolute text-xs bg-violet-100 text-violet-800 dark:bg-gray-700 dark:text-violet-400 px-1 py-0.5 rounded-md flex flex-row items-center justify-center gap-2 transition-all shrink-0',
|
||||
!loading && generatedContent.length > 0
|
||||
? 'bg-green-100 text-green-800 dark:text-green-400 dark:bg-green-700'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class="px-0.5 py-0.5 rounded-md text-2xs text-bold flex flex-row items-center gap-1"
|
||||
>
|
||||
{#if loading}
|
||||
ESC
|
||||
{:else}
|
||||
TAB
|
||||
{/if}
|
||||
{#if loading}
|
||||
<Loader2 class="animate-spin" size={12} />
|
||||
{:else if generatedContent}
|
||||
<Check size={12} />
|
||||
{:else}
|
||||
<Wand2 size={12} />
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
bind:clientHeight={genHeight}
|
||||
class={twMerge(
|
||||
'text-sm leading-6 indent-[3.5rem] text-gray-500 dark:text-gray-400 pr-1',
|
||||
elementType === 'input' ? 'text-ellipsis overflow-hidden whitespace-nowrap' : ''
|
||||
)}
|
||||
style={elementType === 'input' ? `max-width: calc(${width}px - 0.5rem)` : ''}
|
||||
>
|
||||
{generatedContent}
|
||||
<div
|
||||
bind:clientHeight={genHeight}
|
||||
class={twMerge(
|
||||
'text-sm leading-6 indent-[3.5rem] text-gray-500 dark:text-gray-400 pr-1',
|
||||
elementType === 'input' ? 'text-ellipsis overflow-hidden whitespace-nowrap' : ''
|
||||
)}
|
||||
style={elementType === 'input' ? `max-width: calc(${width}px - 0.5rem)` : ''}
|
||||
>
|
||||
{generatedContent}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if elementType === 'textarea'}
|
||||
<div>
|
||||
<div class="flex flex-row-reverse !text-3xs text-tertiary -mt-4">GH Markdown</div>
|
||||
<textarea
|
||||
bind:this={el}
|
||||
bind:value={content}
|
||||
use:autosize
|
||||
{...elementProps}
|
||||
placeholder={!active ? elementProps.placeholder : ''}
|
||||
class={active ? '!indent-[3.5rem]' : ''}
|
||||
on:focus={() => (focused = true)}
|
||||
on:blur={() => (focused = false)}
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if elementType === 'textarea'}
|
||||
<div>
|
||||
<div class="flex flex-row-reverse !text-3xs text-tertiary -mt-4">GH Markdown</div>
|
||||
<textarea
|
||||
{:else}
|
||||
<input
|
||||
bind:this={el}
|
||||
bind:value={content}
|
||||
use:autosize
|
||||
{...elementProps}
|
||||
placeholder={!active ? elementProps.placeholder : ''}
|
||||
class={active ? '!indent-[3.5rem]' : ''}
|
||||
on:focus={() => (focused = true)}
|
||||
on:blur={() => (focused = false)}
|
||||
></textarea>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={el}
|
||||
bind:value={content}
|
||||
placeholder={!active ? elementProps.placeholder : ''}
|
||||
class={active ? '!indent-[3.5rem]' : ''}
|
||||
on:focus={() => (focused = true)}
|
||||
on:blur={() => (focused = false)}
|
||||
/>
|
||||
{/if}
|
||||
<!-- <slot {updateFocus} {active} {generatedContent} classNames={active ? '!indent-[8.8rem]' : ''} /> -->
|
||||
</div>
|
||||
/>
|
||||
{/if}
|
||||
<!-- <slot {updateFocus} {active} {generatedContent} classNames={active ? '!indent-[8.8rem]' : ''} /> -->
|
||||
</div>
|
||||
</TriggerableByAI>
|
||||
|
||||
@@ -1,285 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import AIChatDisplay from './AIChatDisplay.svelte'
|
||||
import { onDestroy, untrack, type Snippet } from 'svelte'
|
||||
import { type ScriptLang } from '$lib/gen'
|
||||
import {
|
||||
dbSchemaTool,
|
||||
prepareScriptSystemMessage,
|
||||
prepareScriptUserMessage,
|
||||
resourceTypeTool,
|
||||
type ScriptChatHelpers
|
||||
} from './script/core'
|
||||
import {
|
||||
chatRequest,
|
||||
type AIChatContext,
|
||||
type DisplayMessage,
|
||||
type Tool,
|
||||
type ToolCallbacks
|
||||
} from './shared'
|
||||
import { onDestroy, setContext, untrack, type Snippet } from 'svelte'
|
||||
import { type OpenFlow, type ScriptLang } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import ContextManager, { type ScriptOptions } from './ContextManager.svelte'
|
||||
import HistoryManager from './HistoryManager.svelte'
|
||||
import {
|
||||
flowTools,
|
||||
prepareFlowSystemMessage,
|
||||
prepareFlowUserMessage,
|
||||
type FlowAIChatHelpers
|
||||
} from './flow/core'
|
||||
import type {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam
|
||||
} from 'openai/resources/index.mjs'
|
||||
import { chatMode, copilotSessionModel, dbSchemas, workspaceStore } from '$lib/stores'
|
||||
copilotInfo,
|
||||
copilotSessionModel,
|
||||
dbSchemas,
|
||||
userStore,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
interface Props {
|
||||
scriptOptions?: ScriptOptions
|
||||
flowHelpers?: FlowAIChatHelpers & {
|
||||
getFlowAndSelectedId: () => { flow: OpenFlow; selectedId: string }
|
||||
}
|
||||
showDiffMode: () => void
|
||||
applyCode: (code: string) => void
|
||||
headerLeft?: Snippet
|
||||
headerRight?: Snippet
|
||||
}
|
||||
|
||||
let { scriptOptions, flowHelpers, applyCode, showDiffMode, headerLeft, headerRight }: Props =
|
||||
$props()
|
||||
let { headerLeft, headerRight }: Props = $props()
|
||||
|
||||
let instructions = $state('')
|
||||
let loading = writable(false)
|
||||
let currentReply: Writable<string> = writable('')
|
||||
let allowedModes = $derived({
|
||||
script: scriptOptions !== undefined,
|
||||
flow: flowHelpers !== undefined
|
||||
})
|
||||
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
const hasCopilot = $derived($copilotInfo.enabled)
|
||||
const disabledMessage = $derived(
|
||||
hasCopilot
|
||||
? ''
|
||||
: isAdmin
|
||||
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
|
||||
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
|
||||
)
|
||||
|
||||
async function updateMode(currentMode: 'script' | 'flow') {
|
||||
if (!allowedModes[currentMode]) {
|
||||
chatMode.set(currentMode === 'script' ? 'flow' : 'script')
|
||||
}
|
||||
const suggestions = [
|
||||
'Where can I see my latest runs?',
|
||||
'How do I trigger a script with a webhook endpoint?',
|
||||
'How can I connect to a database?',
|
||||
'How do I schedule a recurring job?'
|
||||
]
|
||||
|
||||
export async function generateStep(moduleId: string, lang: ScriptLang, instructions: string) {
|
||||
aiChatManager.generateStep(moduleId, lang, instructions)
|
||||
}
|
||||
$effect(() => {
|
||||
updateMode(untrack(() => $chatMode))
|
||||
})
|
||||
|
||||
let displayMessages: DisplayMessage[] = $state([])
|
||||
let abortController: AbortController | undefined = undefined
|
||||
let messages: ChatCompletionMessageParam[] = $state([])
|
||||
|
||||
setContext<AIChatContext>('AIChatContext', {
|
||||
loading,
|
||||
currentReply,
|
||||
canApplyCode: () => allowedModes.script && $chatMode === 'script',
|
||||
applyCode
|
||||
})
|
||||
|
||||
export async function sendRequest(
|
||||
options: {
|
||||
removeDiff?: boolean
|
||||
addBackCode?: boolean
|
||||
instructions?: string
|
||||
mode?: 'script' | 'flow'
|
||||
mode?: 'script' | 'flow' | 'navigator'
|
||||
lang?: ScriptLang | 'bunnative'
|
||||
isPreprocessor?: boolean
|
||||
} = {}
|
||||
) {
|
||||
if (options.mode) {
|
||||
$chatMode = options.mode
|
||||
}
|
||||
if (options.instructions) {
|
||||
instructions = options.instructions
|
||||
}
|
||||
if (!instructions.trim()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const oldSelectedContext = contextManager?.getSelectedContext() ?? []
|
||||
if ($chatMode === 'script') {
|
||||
contextManager?.updateContextOnRequest(options)
|
||||
}
|
||||
loading.set(true)
|
||||
aiChatDisplay?.enableAutomaticScroll()
|
||||
abortController = new AbortController()
|
||||
|
||||
displayMessages = [
|
||||
...displayMessages,
|
||||
{
|
||||
role: 'user',
|
||||
content: instructions,
|
||||
contextElements: $chatMode === 'script' ? oldSelectedContext : undefined
|
||||
}
|
||||
]
|
||||
const oldInstructions = instructions
|
||||
instructions = ''
|
||||
|
||||
const systemMessage =
|
||||
$chatMode === 'script' ? prepareScriptSystemMessage() : prepareFlowSystemMessage()
|
||||
|
||||
if ($chatMode === 'flow' && !flowHelpers) {
|
||||
throw new Error('No flow helpers passed')
|
||||
}
|
||||
|
||||
if ($chatMode === 'script' && !scriptOptions && !options.lang) {
|
||||
throw new Error('No script options passed')
|
||||
}
|
||||
|
||||
const lang = scriptOptions?.lang ?? options.lang ?? 'bun'
|
||||
const isPreprocessor = scriptOptions?.path === 'preprocessor' || options.isPreprocessor
|
||||
|
||||
const userMessage =
|
||||
$chatMode === 'flow'
|
||||
? prepareFlowUserMessage(oldInstructions, flowHelpers!.getFlowAndSelectedId())
|
||||
: await prepareScriptUserMessage(oldInstructions, lang, oldSelectedContext, {
|
||||
isPreprocessor
|
||||
})
|
||||
|
||||
messages.push({ role: 'user', content: userMessage })
|
||||
await historyManager.saveChat(displayMessages, messages)
|
||||
|
||||
$currentReply = ''
|
||||
|
||||
const params: {
|
||||
systemMessage: ChatCompletionSystemMessageParam
|
||||
messages: ChatCompletionMessageParam[]
|
||||
abortController: AbortController
|
||||
callbacks: ToolCallbacks & {
|
||||
onNewToken: (token: string) => void
|
||||
onMessageEnd: () => void
|
||||
}
|
||||
} = {
|
||||
systemMessage,
|
||||
messages,
|
||||
abortController,
|
||||
callbacks: {
|
||||
onNewToken: (token) => currentReply.update((prev) => prev + token),
|
||||
onMessageEnd: () => {
|
||||
if ($currentReply) {
|
||||
displayMessages = [
|
||||
...displayMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: $currentReply,
|
||||
contextElements:
|
||||
$chatMode === 'script'
|
||||
? oldSelectedContext.filter((c) => c.type === 'code')
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
currentReply.set('')
|
||||
},
|
||||
onToolCall: (id, content) => {
|
||||
displayMessages = [...displayMessages, { role: 'tool', tool_call_id: id, content }]
|
||||
},
|
||||
onFinishToolCall: (id, content) => {
|
||||
const existingIdx = displayMessages.findIndex(
|
||||
(m) => m.role === 'tool' && m.tool_call_id === id
|
||||
)
|
||||
if (existingIdx !== -1) {
|
||||
displayMessages[existingIdx].content = content
|
||||
} else {
|
||||
displayMessages.push({ role: 'tool', tool_call_id: id, content })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($chatMode === 'flow') {
|
||||
if (!flowHelpers) {
|
||||
throw new Error('No flow helpers found')
|
||||
}
|
||||
await chatRequest({
|
||||
...params,
|
||||
tools: flowTools,
|
||||
helpers: flowHelpers
|
||||
})
|
||||
} else {
|
||||
const tools: Tool<ScriptChatHelpers>[] = []
|
||||
if (['python3', 'php', 'bun', 'deno', 'nativets', 'bunnative'].includes(lang)) {
|
||||
tools.push(resourceTypeTool)
|
||||
}
|
||||
if (oldSelectedContext.filter((c) => c.type === 'db').length > 0) {
|
||||
tools.push(dbSchemaTool)
|
||||
}
|
||||
await chatRequest({
|
||||
...params,
|
||||
tools,
|
||||
helpers: {
|
||||
getLang: () => lang
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if ($currentReply) {
|
||||
// just in case the onMessageEnd is not called (due to an error for instance)
|
||||
displayMessages = [
|
||||
...displayMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: $currentReply,
|
||||
contextElements:
|
||||
$chatMode === 'script'
|
||||
? oldSelectedContext.filter((c) => c.type === 'code')
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
currentReply.set('')
|
||||
}
|
||||
|
||||
await historyManager.saveChat(displayMessages, messages)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (err instanceof Error) {
|
||||
sendUserToast('Failed to send request: ' + err.message, true)
|
||||
} else {
|
||||
sendUserToast('Failed to send request', true)
|
||||
}
|
||||
} finally {
|
||||
loading.set(false)
|
||||
}
|
||||
aiChatManager.sendRequest(options)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
currentReply.set('')
|
||||
abortController?.abort()
|
||||
aiChatManager.cancel()
|
||||
}
|
||||
|
||||
export function addSelectedLinesToContext(lines: string, startLine: number, endLine: number) {
|
||||
contextManager?.addSelectedLinesToContext(lines, startLine, endLine)
|
||||
}
|
||||
|
||||
export function fix() {
|
||||
instructions = 'Fix the error'
|
||||
contextManager?.setFixContext()
|
||||
sendRequest()
|
||||
}
|
||||
|
||||
export function askAi(
|
||||
prompt: string,
|
||||
options: { withCode?: boolean; withDiff?: boolean } = {
|
||||
withCode: true,
|
||||
withDiff: false
|
||||
}
|
||||
) {
|
||||
if (!scriptOptions) {
|
||||
throw new Error('No script options passed')
|
||||
}
|
||||
instructions = prompt
|
||||
contextManager.setAskAiContext(options)
|
||||
sendRequest({
|
||||
removeDiff: options.withDiff,
|
||||
addBackCode: options.withCode === false
|
||||
})
|
||||
if (options.withDiff) {
|
||||
showDiffMode()
|
||||
}
|
||||
aiChatManager.contextManager.addSelectedLinesToContext(lines, startLine, endLine)
|
||||
}
|
||||
|
||||
export function focusTextArea() {
|
||||
aiChatDisplay?.focusInput()
|
||||
}
|
||||
|
||||
const historyManager = new HistoryManager()
|
||||
const historyManager = aiChatManager.historyManager
|
||||
historyManager.init()
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -288,73 +74,72 @@
|
||||
})
|
||||
|
||||
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
|
||||
// let contextManager: ContextManager | undefined = $state(undefined)
|
||||
|
||||
const contextManager = new ContextManager()
|
||||
|
||||
$effect(() => {
|
||||
if (scriptOptions) {
|
||||
contextManager.updateAvailableContext(
|
||||
scriptOptions,
|
||||
$dbSchemas,
|
||||
$workspaceStore ?? '',
|
||||
!$copilotSessionModel?.model.endsWith('/thinking'),
|
||||
untrack(() => contextManager.getSelectedContext())
|
||||
)
|
||||
}
|
||||
aiChatManager.listenForDbSchemasChanges($dbSchemas)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
displayMessages = ContextManager.updateDisplayMessages(
|
||||
untrack(() => displayMessages),
|
||||
$dbSchemas
|
||||
aiChatManager.listenForScriptEditorContextChange(
|
||||
$dbSchemas,
|
||||
$workspaceStore,
|
||||
$copilotSessionModel
|
||||
)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
aiChatManager.updateMode(untrack(() => aiChatManager.mode))
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
e.preventDefault()
|
||||
aiChatManager.toggleOpen()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatDisplay
|
||||
bind:this={aiChatDisplay}
|
||||
{allowedModes}
|
||||
allowedModes={aiChatManager.allowedModes}
|
||||
pastChats={historyManager.getPastChats()}
|
||||
bind:selectedContext={
|
||||
() => contextManager.getSelectedContext(),
|
||||
() => aiChatManager.contextManager.getSelectedContext(),
|
||||
(sc) => {
|
||||
scriptOptions && contextManager.setSelectedContext(sc)
|
||||
aiChatManager.scriptEditorOptions && aiChatManager.contextManager.setSelectedContext(sc)
|
||||
}
|
||||
}
|
||||
availableContext={contextManager.getAvailableContext()}
|
||||
messages={$currentReply
|
||||
availableContext={aiChatManager.contextManager.getAvailableContext()}
|
||||
messages={aiChatManager.currentReply
|
||||
? [
|
||||
...displayMessages,
|
||||
...aiChatManager.displayMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: $currentReply,
|
||||
contextElements: contextManager.getSelectedContext().filter((c) => c.type === 'code')
|
||||
content: aiChatManager.currentReply,
|
||||
contextElements: aiChatManager.contextManager
|
||||
.getSelectedContext()
|
||||
.filter((c) => c.type === 'code')
|
||||
}
|
||||
]
|
||||
: displayMessages}
|
||||
bind:instructions
|
||||
{sendRequest}
|
||||
saveAndClear={async () => {
|
||||
await historyManager.save(displayMessages, messages)
|
||||
displayMessages = []
|
||||
messages = []
|
||||
: aiChatManager.displayMessages}
|
||||
saveAndClear={aiChatManager.saveAndClear}
|
||||
deletePastChat={(id) => {
|
||||
historyManager.deletePastChat(id)
|
||||
}}
|
||||
deletePastChat={historyManager.deletePastChat}
|
||||
loadPastChat={(id) => {
|
||||
const chat = historyManager.loadPastChat(id)
|
||||
if (chat) {
|
||||
displayMessages = ContextManager.updateDisplayMessages(chat.displayMessages, $dbSchemas)
|
||||
messages = chat.actualMessages
|
||||
aiChatDisplay?.enableAutomaticScroll()
|
||||
}
|
||||
aiChatManager.loadPastChat(id)
|
||||
}}
|
||||
{cancel}
|
||||
{askAi}
|
||||
askAi={aiChatManager.askAi}
|
||||
{headerLeft}
|
||||
{headerRight}
|
||||
hasDiff={scriptOptions &&
|
||||
!!scriptOptions.lastDeployedCode &&
|
||||
scriptOptions.lastDeployedCode !== scriptOptions.code}
|
||||
diffMode={scriptOptions?.diffMode ?? false}
|
||||
hasDiff={aiChatManager.scriptEditorOptions &&
|
||||
!!aiChatManager.scriptEditorOptions.lastDeployedCode &&
|
||||
aiChatManager.scriptEditorOptions.lastDeployedCode !== aiChatManager.scriptEditorOptions.code}
|
||||
diffMode={aiChatManager.scriptEditorOptions?.diffMode ?? false}
|
||||
disabled={!hasCopilot}
|
||||
{disabledMessage}
|
||||
{suggestions}
|
||||
></AIChatDisplay>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import AssistantMessage from './AssistantMessage.svelte'
|
||||
import { getContext, type Snippet } from 'svelte'
|
||||
import { type Snippet } from 'svelte'
|
||||
import { HistoryIcon, Loader2, Plus, StopCircleIcon, X } from 'lucide-svelte'
|
||||
import autosize from '$lib/autosize'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { type AIChatContext, type DisplayMessage } from './shared'
|
||||
import { type DisplayMessage } from './shared'
|
||||
import type { ContextElement } from './context'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import ContextTextarea from './ContextTextarea.svelte'
|
||||
@@ -14,38 +14,39 @@
|
||||
import ChatQuickActions from './ChatQuickActions.svelte'
|
||||
import ProviderModelSelector from './ProviderModelSelector.svelte'
|
||||
import ChatMode from './ChatMode.svelte'
|
||||
import { chatMode } from '$lib/stores'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
|
||||
let {
|
||||
allowedModes,
|
||||
messages,
|
||||
instructions = $bindable(),
|
||||
pastChats,
|
||||
hasDiff,
|
||||
diffMode = false, // todo: remove default
|
||||
selectedContext = $bindable([]), // todo: remove default
|
||||
availableContext = [], // todo: remove default
|
||||
sendRequest,
|
||||
loadPastChat,
|
||||
deletePastChat,
|
||||
saveAndClear,
|
||||
cancel,
|
||||
askAi = () => {}, // todo: remove default,
|
||||
headerLeft,
|
||||
headerRight
|
||||
headerRight,
|
||||
disabled = false,
|
||||
disabledMessage = '',
|
||||
suggestions = []
|
||||
}: {
|
||||
allowedModes: {
|
||||
script: boolean
|
||||
flow: boolean
|
||||
navigator: boolean
|
||||
}
|
||||
messages: DisplayMessage[]
|
||||
instructions: string
|
||||
pastChats: { id: string; title: string }[]
|
||||
hasDiff?: boolean
|
||||
diffMode: boolean
|
||||
selectedContext: ContextElement[]
|
||||
availableContext: ContextElement[]
|
||||
sendRequest: () => void
|
||||
loadPastChat: (id: string) => void
|
||||
deletePastChat: (id: string) => void
|
||||
saveAndClear: () => void
|
||||
@@ -53,21 +54,17 @@
|
||||
askAi?: (instructions: string, options?: { withCode?: boolean; withDiff?: boolean }) => void
|
||||
headerLeft?: Snippet
|
||||
headerRight?: Snippet
|
||||
disabled?: boolean
|
||||
disabledMessage?: string
|
||||
suggestions?: string[]
|
||||
} = $props()
|
||||
|
||||
const { loading, currentReply } = getContext<AIChatContext>('AIChatContext')
|
||||
|
||||
export function enableAutomaticScroll() {
|
||||
automaticScroll = true
|
||||
}
|
||||
|
||||
let contextTextareaComponent: ContextTextarea | undefined = $state()
|
||||
|
||||
export function focusInput() {
|
||||
contextTextareaComponent?.focus()
|
||||
}
|
||||
|
||||
let automaticScroll = $state(true)
|
||||
let scrollEl: HTMLDivElement | undefined = $state()
|
||||
async function scrollDown() {
|
||||
scrollEl?.scrollTo({
|
||||
@@ -78,7 +75,7 @@
|
||||
|
||||
let height = $state(0)
|
||||
$effect(() => {
|
||||
automaticScroll && height && scrollDown()
|
||||
aiChatManager.automaticScroll && height && scrollDown()
|
||||
})
|
||||
|
||||
function addContextToSelection(contextElement: ContextElement) {
|
||||
@@ -105,6 +102,11 @@
|
||||
return -1
|
||||
}
|
||||
|
||||
function submitSuggestion(suggestion: string) {
|
||||
aiChatManager.instructions = suggestion
|
||||
aiChatManager.sendRequest()
|
||||
}
|
||||
|
||||
const lastUserMessageIndex = $derived(findLastIndex(messages, (m) => m.role === 'user'))
|
||||
</script>
|
||||
|
||||
@@ -189,7 +191,7 @@
|
||||
class="h-full overflow-y-scroll pt-2"
|
||||
bind:this={scrollEl}
|
||||
onwheel={(e) => {
|
||||
automaticScroll = false
|
||||
aiChatManager.automaticScroll = false
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col" bind:clientHeight={height}>
|
||||
@@ -207,7 +209,7 @@
|
||||
message.role === 'user' &&
|
||||
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg',
|
||||
message.role === 'user'
|
||||
? $loading && lastUserMessageIndex === messageIndex
|
||||
? aiChatManager.loading && lastUserMessageIndex === messageIndex
|
||||
? 'mb-1'
|
||||
: 'mb-2'
|
||||
: '',
|
||||
@@ -221,7 +223,7 @@
|
||||
{message.content}
|
||||
{/if}
|
||||
</div>
|
||||
{#if message.role === 'user' && $loading && lastUserMessageIndex === messageIndex}
|
||||
{#if message.role === 'user' && aiChatManager.loading && lastUserMessageIndex === messageIndex}
|
||||
<div class="flex flex-row px-2 mb-2">
|
||||
<Button
|
||||
startIcon={{ icon: StopCircleIcon }}
|
||||
@@ -238,7 +240,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if $loading && !$currentReply}
|
||||
{#if aiChatManager.loading && !aiChatManager.currentReply}
|
||||
<div class="mb-6 py-1 px-2">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
@@ -248,7 +250,7 @@
|
||||
{/if}
|
||||
|
||||
<div class:border-t={messages.length > 0}>
|
||||
{#if $chatMode === 'script'}
|
||||
{#if aiChatManager.mode === 'script'}
|
||||
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 px-2 no-scrollbar">
|
||||
<Popover>
|
||||
<svelte:fragment slot="trigger">
|
||||
@@ -282,47 +284,71 @@
|
||||
</div>
|
||||
<ContextTextarea
|
||||
bind:this={contextTextareaComponent}
|
||||
{instructions}
|
||||
instructions={aiChatManager.instructions}
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
isFirstMessage={messages.length === 0}
|
||||
on:addContext={(e) => addContextToSelection(e.detail.contextElement)}
|
||||
on:sendRequest={() => {
|
||||
if (!$loading) {
|
||||
sendRequest()
|
||||
if (!aiChatManager.loading) {
|
||||
aiChatManager.sendRequest()
|
||||
}
|
||||
}}
|
||||
on:updateInstructions={(e) => (instructions = e.detail.value)}
|
||||
on:updateInstructions={(e) => (aiChatManager.instructions = e.detail.value)}
|
||||
{disabled}
|
||||
/>
|
||||
{:else}
|
||||
<div class="relative w-full px-2 scroll-pb-2 pt-2">
|
||||
<textarea
|
||||
bind:value={instructions}
|
||||
bind:value={aiChatManager.instructions}
|
||||
use:autosize
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !$loading) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !aiChatManager.loading) {
|
||||
e.preventDefault()
|
||||
sendRequest()
|
||||
aiChatManager.sendRequest()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={messages.length === 0 ? 'Ask anything' : 'Ask followup'}
|
||||
class="resize-none"
|
||||
{disabled}
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class={`flex flex-row ${
|
||||
$chatMode === 'script' && hasDiff ? 'justify-between' : 'justify-end'
|
||||
aiChatManager.mode === 'script' && hasDiff ? 'justify-between' : 'justify-end'
|
||||
} items-center px-0.5`}
|
||||
>
|
||||
{#if $chatMode === 'script' && hasDiff}
|
||||
{#if aiChatManager.mode === 'script' && hasDiff}
|
||||
<ChatQuickActions {askAi} {diffMode} />
|
||||
{/if}
|
||||
<div class="flex flex-row gap-2 min-w-0">
|
||||
<ChatMode {allowedModes} />
|
||||
<ProviderModelSelector />
|
||||
</div>
|
||||
{#if disabled}
|
||||
<div class="text-tertiary text-xs mt-2 px-2">
|
||||
<Markdown md={disabledMessage} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-2 min-w-0">
|
||||
<ChatMode {allowedModes} />
|
||||
<ProviderModelSelector />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if aiChatManager.mode === 'navigator' && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
|
||||
<div class="px-2 mt-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each suggestions as suggestion}
|
||||
<Button
|
||||
on:click={() => submitSuggestion(suggestion)}
|
||||
size="xs2"
|
||||
color="light"
|
||||
btnClasses="whitespace-normal text-center font-normal"
|
||||
>
|
||||
{suggestion}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
import type { AIProviderModel, ScriptLang } from '$lib/gen/types.gen'
|
||||
import type { ScriptOptions } from './ContextManager.svelte'
|
||||
import {
|
||||
flowTools,
|
||||
prepareFlowSystemMessage,
|
||||
prepareFlowUserMessage,
|
||||
type FlowAIChatHelpers
|
||||
} from './flow/core'
|
||||
import ContextManager from './ContextManager.svelte'
|
||||
import HistoryManager from './HistoryManager.svelte'
|
||||
import { processToolCall, type DisplayMessage, type Tool, type ToolCallbacks } from './shared'
|
||||
import type {
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionUserMessageParam
|
||||
} from 'openai/resources/chat/completions.mjs'
|
||||
import { prepareScriptSystemMessage, prepareScriptTools } from './script/core'
|
||||
import { navigatorTools, prepareNavigatorSystemMessage } from './navigator/core'
|
||||
import { prepareScriptUserMessage } from './script/core'
|
||||
import { prepareNavigatorUserMessage } from './navigator/core'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { getCompletion } from '../lib'
|
||||
import { dfs } from '$lib/components/flows/previousResults'
|
||||
import { getStringError } from './utils'
|
||||
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
|
||||
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
import { untrack } from 'svelte'
|
||||
import type { DBSchemas } from '$lib/stores'
|
||||
|
||||
type TriggerablesMap = Record<
|
||||
string,
|
||||
{ description: string; onTrigger: ((value?: string) => void) | undefined }
|
||||
>
|
||||
|
||||
class AIChatManager {
|
||||
DEFAULT_SIZE = 22
|
||||
NAVIGATION_SYSTEM_PROMPT = `
|
||||
CONSIDERATIONS:
|
||||
- You are provided with a tool to switch to navigation mode, only use it when you are sure that the user is asking you to navigate the application or help them find something. Do not use it otherwise.
|
||||
`
|
||||
|
||||
contextManager = new ContextManager()
|
||||
historyManager = new HistoryManager()
|
||||
abortController: AbortController | undefined = undefined
|
||||
|
||||
size = $state<number>(localStorage.getItem('ai-chat-open') === 'true' ? this.DEFAULT_SIZE : 0)
|
||||
savedSize = $state<number>(0)
|
||||
instructions = $state<string>('')
|
||||
pendingPrompt = $state<string>('')
|
||||
loading = $state<boolean>(false)
|
||||
currentReply = $state<string>('')
|
||||
displayMessages = $state<DisplayMessage[]>([])
|
||||
messages = $state<ChatCompletionMessageParam[]>([])
|
||||
automaticScroll = $state<boolean>(true)
|
||||
systemMessage = $state<ChatCompletionSystemMessageParam>({
|
||||
role: 'system',
|
||||
content: ''
|
||||
})
|
||||
tools = $state<Tool<any>[]>([])
|
||||
helpers = $state<any | undefined>(undefined)
|
||||
|
||||
triggerablesByAI = $state<TriggerablesMap>({})
|
||||
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
|
||||
scriptEditorApplyCode = $state<((code: string) => void) | undefined>(undefined)
|
||||
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
|
||||
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
|
||||
mode = $state<'script' | 'flow' | 'navigator'>('navigator')
|
||||
|
||||
allowedModes = $derived({
|
||||
script: this.scriptEditorOptions !== undefined,
|
||||
flow: this.flowAiChatHelpers !== undefined,
|
||||
navigator: true
|
||||
})
|
||||
|
||||
open = $derived(this.size > 0)
|
||||
|
||||
updateMode(currentMode: 'script' | 'flow' | 'navigator') {
|
||||
if (
|
||||
!this.allowedModes[currentMode] &&
|
||||
Object.keys(this.allowedModes).filter((k) => this.allowedModes[k]).length === 1
|
||||
) {
|
||||
const firstKey = Object.keys(this.allowedModes).filter((k) => this.allowedModes[k])[0]
|
||||
this.changeMode(firstKey as 'script' | 'flow' | 'navigator')
|
||||
}
|
||||
}
|
||||
|
||||
changeMode(
|
||||
mode: 'script' | 'flow' | 'navigator',
|
||||
pendingPrompt?: string,
|
||||
options?: {
|
||||
closeScriptSettings?: boolean
|
||||
}
|
||||
) {
|
||||
this.mode = mode
|
||||
this.pendingPrompt = pendingPrompt ?? ''
|
||||
if (mode === 'script') {
|
||||
this.systemMessage = prepareScriptSystemMessage()
|
||||
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
|
||||
const context = this.contextManager.getSelectedContext()
|
||||
const lang = this.scriptEditorOptions?.lang ?? 'bun'
|
||||
this.tools = [this.changeModeTool, ...prepareScriptTools(lang, context)]
|
||||
this.helpers = {
|
||||
getLang: () => lang
|
||||
}
|
||||
if (options?.closeScriptSettings) {
|
||||
const closeComponent = this.triggerablesByAI['close-script-builder-settings']
|
||||
if (closeComponent) {
|
||||
closeComponent.onTrigger?.()
|
||||
}
|
||||
}
|
||||
} else if (mode === 'flow') {
|
||||
this.systemMessage = prepareFlowSystemMessage()
|
||||
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
|
||||
this.tools = [this.changeModeTool, ...flowTools]
|
||||
this.helpers = this.flowAiChatHelpers
|
||||
} else if (mode === 'navigator') {
|
||||
this.systemMessage = prepareNavigatorSystemMessage()
|
||||
this.tools = [this.changeModeTool, ...navigatorTools]
|
||||
this.helpers = {}
|
||||
}
|
||||
}
|
||||
|
||||
canApplyCode = $derived(this.allowedModes.script && this.mode === 'script')
|
||||
|
||||
private changeModeTool = {
|
||||
def: {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'change_mode',
|
||||
description:
|
||||
'Change the AI mode to the one specified. Script mode is used to create scripts, and flow mode is used to create flows. Navigator mode is used to navigate the application and help the user find what they are looking for.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
description: 'The mode to change to',
|
||||
enum: ['script', 'flow', 'navigator']
|
||||
},
|
||||
pendingPrompt: {
|
||||
type: 'string',
|
||||
description: 'The prompt to send to the new mode to fulfill the user request',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
required: ['mode']
|
||||
}
|
||||
}
|
||||
},
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.onToolCall(toolId, 'Switching to ' + args.mode + ' mode...')
|
||||
this.changeMode(args.mode as 'script' | 'flow' | 'navigator', args.pendingPrompt, {
|
||||
closeScriptSettings: true
|
||||
})
|
||||
toolCallbacks.onFinishToolCall(toolId, 'Switched to ' + args.mode + ' mode')
|
||||
return 'Mode changed to ' + args.mode
|
||||
}
|
||||
}
|
||||
|
||||
openChat = () => {
|
||||
this.size = this.savedSize > 0 ? this.savedSize : this.DEFAULT_SIZE
|
||||
localStorage.setItem('ai-chat-open', 'true')
|
||||
}
|
||||
|
||||
closeChat = () => {
|
||||
this.savedSize = this.size
|
||||
this.size = 0
|
||||
localStorage.setItem('ai-chat-open', 'false')
|
||||
}
|
||||
|
||||
toggleOpen = () => {
|
||||
if (this.size > 0) {
|
||||
this.savedSize = this.size
|
||||
}
|
||||
this.size = this.size === 0 ? (this.savedSize > 0 ? this.savedSize : this.DEFAULT_SIZE) : 0
|
||||
localStorage.setItem('ai-chat-open', this.size === 0 ? 'false' : 'true')
|
||||
}
|
||||
|
||||
askAi = (
|
||||
prompt: string,
|
||||
options: { withCode?: boolean; withDiff?: boolean } = {
|
||||
withCode: true,
|
||||
withDiff: false
|
||||
}
|
||||
) => {
|
||||
if (this.scriptEditorOptions) {
|
||||
this.contextManager.setAskAiContext(options)
|
||||
}
|
||||
this.instructions = prompt
|
||||
this.sendRequest({
|
||||
removeDiff: options.withDiff,
|
||||
addBackCode: options.withCode === false
|
||||
})
|
||||
if (options.withDiff) {
|
||||
this.scriptEditorShowDiffMode?.()
|
||||
}
|
||||
}
|
||||
|
||||
private chatRequest = async ({
|
||||
messages,
|
||||
abortController,
|
||||
callbacks
|
||||
}: {
|
||||
messages: ChatCompletionMessageParam[]
|
||||
abortController: AbortController
|
||||
callbacks: ToolCallbacks & {
|
||||
onNewToken: (token: string) => void
|
||||
onMessageEnd: () => void
|
||||
}
|
||||
}) => {
|
||||
try {
|
||||
let completion: any = null
|
||||
|
||||
while (true) {
|
||||
const systemMessage = this.systemMessage
|
||||
const tools = this.tools
|
||||
const helpers = this.helpers
|
||||
|
||||
let pendingPrompt = this.pendingPrompt
|
||||
let pendingUserMessage: ChatCompletionUserMessageParam | undefined = undefined
|
||||
if (pendingPrompt) {
|
||||
if (this.mode === 'script') {
|
||||
pendingUserMessage = await prepareScriptUserMessage(
|
||||
pendingPrompt,
|
||||
this.scriptEditorOptions?.lang as ScriptLang | 'bunnative',
|
||||
this.contextManager.getSelectedContext()
|
||||
)
|
||||
} else if (this.mode === 'flow') {
|
||||
pendingUserMessage = prepareFlowUserMessage(
|
||||
pendingPrompt,
|
||||
this.flowAiChatHelpers!.getFlowAndSelectedId()
|
||||
)
|
||||
} else if (this.mode === 'navigator') {
|
||||
pendingUserMessage = prepareNavigatorUserMessage(pendingPrompt)
|
||||
}
|
||||
this.pendingPrompt = ''
|
||||
}
|
||||
completion = await getCompletion(
|
||||
[systemMessage, ...messages, ...(pendingUserMessage ? [pendingUserMessage] : [])],
|
||||
abortController,
|
||||
tools.map((t) => t.def)
|
||||
)
|
||||
|
||||
if (completion) {
|
||||
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
|
||||
|
||||
let answer = ''
|
||||
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) {
|
||||
answer += delta
|
||||
callbacks.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
messages.push({ role: 'assistant', content: answer })
|
||||
}
|
||||
|
||||
callbacks.onMessageEnd()
|
||||
|
||||
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.map((t) => ({
|
||||
...t,
|
||||
function: {
|
||||
...t.function,
|
||||
arguments: t.function.arguments || '{}'
|
||||
}
|
||||
}))
|
||||
})
|
||||
for (const toolCall of toolCalls) {
|
||||
await processToolCall({
|
||||
tools,
|
||||
toolCall,
|
||||
messages,
|
||||
helpers,
|
||||
toolCallbacks: callbacks
|
||||
})
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
throw err
|
||||
} else {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendRequest = async (
|
||||
options: {
|
||||
removeDiff?: boolean
|
||||
addBackCode?: boolean
|
||||
instructions?: string
|
||||
mode?: 'script' | 'flow' | 'navigator'
|
||||
lang?: ScriptLang | 'bunnative'
|
||||
isPreprocessor?: boolean
|
||||
} = {}
|
||||
) => {
|
||||
if (options.mode) {
|
||||
this.changeMode(options.mode, '')
|
||||
} else {
|
||||
this.changeMode(this.mode, '')
|
||||
}
|
||||
if (options.instructions) {
|
||||
this.instructions = options.instructions
|
||||
}
|
||||
if (!this.instructions.trim()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const oldSelectedContext = this.contextManager?.getSelectedContext() ?? []
|
||||
if (this.mode === 'script') {
|
||||
this.contextManager?.updateContextOnRequest(options)
|
||||
}
|
||||
this.loading = true
|
||||
this.automaticScroll = true
|
||||
this.abortController = new AbortController()
|
||||
|
||||
this.displayMessages = [
|
||||
...this.displayMessages,
|
||||
{
|
||||
role: 'user',
|
||||
content: this.instructions,
|
||||
contextElements: this.mode === 'script' ? oldSelectedContext : undefined
|
||||
}
|
||||
]
|
||||
const oldInstructions = this.instructions
|
||||
this.instructions = ''
|
||||
|
||||
if (this.mode === 'script' && !this.scriptEditorOptions && !options.lang) {
|
||||
throw new Error('No script options passed')
|
||||
}
|
||||
|
||||
const lang = this.scriptEditorOptions?.lang ?? options.lang ?? 'bun'
|
||||
const isPreprocessor =
|
||||
this.scriptEditorOptions?.path === 'preprocessor' || options.isPreprocessor
|
||||
|
||||
const userMessage =
|
||||
this.mode === 'flow'
|
||||
? prepareFlowUserMessage(oldInstructions, this.flowAiChatHelpers!.getFlowAndSelectedId())
|
||||
: this.mode === 'navigator'
|
||||
? prepareNavigatorUserMessage(oldInstructions)
|
||||
: await prepareScriptUserMessage(oldInstructions, lang, oldSelectedContext, {
|
||||
isPreprocessor
|
||||
})
|
||||
|
||||
this.messages.push(userMessage)
|
||||
await this.historyManager.saveChat(this.displayMessages, this.messages)
|
||||
|
||||
this.currentReply = ''
|
||||
|
||||
const params: {
|
||||
messages: ChatCompletionMessageParam[]
|
||||
abortController: AbortController
|
||||
callbacks: ToolCallbacks & {
|
||||
onNewToken: (token: string) => void
|
||||
onMessageEnd: () => void
|
||||
}
|
||||
} = {
|
||||
messages: this.messages,
|
||||
abortController: this.abortController,
|
||||
callbacks: {
|
||||
onNewToken: (token) => (this.currentReply += token),
|
||||
onMessageEnd: () => {
|
||||
if (this.currentReply) {
|
||||
this.displayMessages = [
|
||||
...this.displayMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: this.currentReply,
|
||||
contextElements:
|
||||
this.mode === 'script'
|
||||
? oldSelectedContext.filter((c) => c.type === 'code')
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
this.currentReply = ''
|
||||
},
|
||||
onToolCall: (id, content) => {
|
||||
this.displayMessages = [
|
||||
...this.displayMessages,
|
||||
{ role: 'tool', tool_call_id: id, content }
|
||||
]
|
||||
},
|
||||
onFinishToolCall: (id, content) => {
|
||||
console.log('onFinishToolCall', id, content)
|
||||
const existingIdx = this.displayMessages.findIndex(
|
||||
(m) => m.role === 'tool' && m.tool_call_id === id
|
||||
)
|
||||
if (existingIdx !== -1) {
|
||||
this.displayMessages[existingIdx].content = content
|
||||
} else {
|
||||
this.displayMessages.push({ role: 'tool', tool_call_id: id, content })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mode === 'flow' && !this.flowAiChatHelpers) {
|
||||
throw new Error('No flow helpers found')
|
||||
}
|
||||
await this.chatRequest({
|
||||
...params
|
||||
})
|
||||
if (this.currentReply) {
|
||||
// just in case the onMessageEnd is not called (due to an error for instance)
|
||||
this.displayMessages = [
|
||||
...this.displayMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: this.currentReply,
|
||||
contextElements:
|
||||
this.mode === 'script'
|
||||
? oldSelectedContext.filter((c) => c.type === 'code')
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
this.currentReply = ''
|
||||
}
|
||||
|
||||
await this.historyManager.saveChat(this.displayMessages, this.messages)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (err instanceof Error) {
|
||||
sendUserToast('Failed to send request: ' + err.message, true)
|
||||
} else {
|
||||
sendUserToast('Failed to send request', true)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
cancel = () => {
|
||||
this.currentReply = ''
|
||||
this.abortController?.abort()
|
||||
}
|
||||
|
||||
fix = () => {
|
||||
this.instructions = 'Fix the error'
|
||||
this.contextManager?.setFixContext()
|
||||
this.sendRequest()
|
||||
}
|
||||
|
||||
addSelectedLinesToContext = (lines: string, startLine: number, endLine: number) => {
|
||||
this.contextManager?.addSelectedLinesToContext(lines, startLine, endLine)
|
||||
}
|
||||
|
||||
saveAndClear = async () => {
|
||||
await this.historyManager.save(this.displayMessages, this.messages)
|
||||
this.displayMessages = []
|
||||
this.messages = []
|
||||
}
|
||||
|
||||
loadPastChat = async (id: string) => {
|
||||
const chat = this.historyManager.loadPastChat(id)
|
||||
if (chat) {
|
||||
this.displayMessages = chat.displayMessages
|
||||
this.messages = chat.actualMessages
|
||||
this.automaticScroll = true
|
||||
}
|
||||
}
|
||||
|
||||
generateStep = async (moduleId: string, lang: ScriptLang, instructions: string) => {
|
||||
if (!this.flowAiChatHelpers) {
|
||||
throw new Error('No flow helpers found')
|
||||
}
|
||||
this.flowAiChatHelpers.selectStep(moduleId)
|
||||
await this.sendRequest({
|
||||
instructions: instructions,
|
||||
mode: 'script',
|
||||
lang: lang,
|
||||
isPreprocessor: moduleId === 'preprocessor'
|
||||
})
|
||||
}
|
||||
|
||||
listenForScriptEditorContextChange = (
|
||||
dbSchemas: DBSchemas,
|
||||
workspaceStore: string | undefined,
|
||||
copilotSessionModel: AIProviderModel | undefined
|
||||
) => {
|
||||
if (this.scriptEditorOptions) {
|
||||
this.contextManager.updateAvailableContext(
|
||||
this.scriptEditorOptions,
|
||||
dbSchemas,
|
||||
workspaceStore ?? '',
|
||||
!copilotSessionModel?.model.endsWith('/thinking'),
|
||||
untrack(() => this.contextManager.getSelectedContext())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listenForDbSchemasChanges = (dbSchemas: DBSchemas) => {
|
||||
this.displayMessages = ContextManager.updateDisplayMessages(
|
||||
untrack(() => this.displayMessages),
|
||||
dbSchemas
|
||||
)
|
||||
}
|
||||
|
||||
listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => {
|
||||
if (currentEditor && currentEditor.type === 'script') {
|
||||
this.scriptEditorApplyCode = (code) => {
|
||||
if (currentEditor && currentEditor.type === 'script') {
|
||||
currentEditor.hideDiffMode()
|
||||
currentEditor.editor.reviewAndApplyCode(code)
|
||||
}
|
||||
}
|
||||
this.scriptEditorShowDiffMode = () => {
|
||||
if (currentEditor && currentEditor.type === 'script') {
|
||||
currentEditor.showDiffMode()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.scriptEditorApplyCode = undefined
|
||||
this.scriptEditorShowDiffMode = undefined
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.scriptEditorApplyCode = undefined
|
||||
this.scriptEditorShowDiffMode = undefined
|
||||
}
|
||||
}
|
||||
|
||||
listenForSelectedIdChanges = (
|
||||
selectedId: string,
|
||||
flowStore: ExtendedOpenFlow,
|
||||
flowStateStore: FlowState,
|
||||
currentEditor: CurrentEditor
|
||||
) => {
|
||||
function getModule(id: string) {
|
||||
if (id === 'preprocessor') {
|
||||
return flowStore.value.preprocessor_module
|
||||
} else if (id === 'failure') {
|
||||
return flowStore.value.failure_module
|
||||
} else {
|
||||
return dfs(id, flowStore, false)[0]
|
||||
}
|
||||
}
|
||||
|
||||
function getScriptOptions(id: string): ScriptOptions | undefined {
|
||||
const module = getModule(id)
|
||||
|
||||
if (module && module.value.type === 'rawscript') {
|
||||
const moduleState: FlowModuleState | undefined = flowStateStore[module.id]
|
||||
|
||||
const editorRelated =
|
||||
currentEditor && currentEditor.type === 'script' && currentEditor.stepId === module.id
|
||||
? {
|
||||
diffMode: currentEditor.diffMode,
|
||||
lastDeployedCode: currentEditor.lastDeployedCode,
|
||||
lastSavedCode: undefined
|
||||
}
|
||||
: {
|
||||
diffMode: false,
|
||||
lastDeployedCode: undefined,
|
||||
lastSavedCode: undefined
|
||||
}
|
||||
|
||||
return {
|
||||
args: moduleState?.previewArgs ?? {},
|
||||
error:
|
||||
moduleState && !moduleState.previewSuccess
|
||||
? getStringError(moduleState.previewResult)
|
||||
: undefined,
|
||||
code: module.value.content,
|
||||
lang: module.value.language,
|
||||
path: module.id,
|
||||
...editorRelated
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (selectedId) {
|
||||
const options = getScriptOptions(selectedId)
|
||||
if (options) {
|
||||
this.scriptEditorOptions = options
|
||||
}
|
||||
} else {
|
||||
this.scriptEditorOptions = undefined
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.scriptEditorOptions = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => {
|
||||
this.flowAiChatHelpers = flowHelpers
|
||||
|
||||
return () => {
|
||||
this.flowAiChatHelpers = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const aiChatManager = new AIChatManager()
|
||||
@@ -3,6 +3,7 @@
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import type { DisplayMessage } from './shared'
|
||||
import CodeDisplay from './script/CodeDisplay.svelte'
|
||||
import LinkRenderer from './LinkRenderer.svelte'
|
||||
import { setContext } from 'svelte'
|
||||
|
||||
export let message: DisplayMessage
|
||||
@@ -21,7 +22,8 @@
|
||||
gfmPlugin(),
|
||||
{
|
||||
renderer: {
|
||||
pre: CodeDisplay
|
||||
pre: CodeDisplay,
|
||||
a: LinkRenderer
|
||||
}
|
||||
}
|
||||
]}
|
||||
|
||||
@@ -2,28 +2,31 @@
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { chatMode } from '$lib/stores'
|
||||
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
let {
|
||||
allowedModes
|
||||
}: {
|
||||
allowedModes: {
|
||||
script: boolean
|
||||
flow: boolean
|
||||
navigator: boolean
|
||||
}
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Popover disablePopup={!allowedModes.script || !allowedModes.flow} class="max-w-full">
|
||||
<Popover
|
||||
disablePopup={Object.keys(allowedModes).filter((k) => allowedModes[k]).length < 2}
|
||||
class="max-w-full"
|
||||
>
|
||||
<svelte:fragment slot="trigger">
|
||||
<div
|
||||
class="text-tertiary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
|
||||
>
|
||||
<span class={`truncate`}>
|
||||
{$chatMode} mode
|
||||
{aiChatManager.mode} mode
|
||||
</span>
|
||||
{#if allowedModes.script && allowedModes.flow}
|
||||
{#if Object.keys(allowedModes).filter((k) => allowedModes[k]).length > 1}
|
||||
<div class="shrink-0">
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
@@ -32,19 +35,21 @@
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content" let:close>
|
||||
<div class="flex flex-col gap-1 p-1 min-w-24">
|
||||
{#each ['script', 'flow'] as possibleMode}
|
||||
<button
|
||||
class={twMerge(
|
||||
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
|
||||
$chatMode === possibleMode && 'bg-surface-hover'
|
||||
)}
|
||||
onclick={() => {
|
||||
$chatMode = possibleMode as 'script' | 'flow'
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{possibleMode} mode
|
||||
</button>
|
||||
{#each ['script', 'flow', 'navigator'] as possibleMode}
|
||||
{#if allowedModes[possibleMode]}
|
||||
<button
|
||||
class={twMerge(
|
||||
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
|
||||
aiChatManager.mode === possibleMode && 'bg-surface-hover'
|
||||
)}
|
||||
onclick={() => {
|
||||
aiChatManager.changeMode(possibleMode as 'script' | 'flow' | 'navigator')
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{possibleMode} mode
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export let availableContext: ContextElement[]
|
||||
export let selectedContext: ContextElement[]
|
||||
export let isFirstMessage: boolean
|
||||
export let disabled: boolean = false
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
updateInstructions: { value: string }
|
||||
@@ -319,6 +320,7 @@
|
||||
style={instructions.length > 0
|
||||
? 'color: transparent; -webkit-text-fill-color: transparent;'
|
||||
: ''}
|
||||
{disabled}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
href?: string
|
||||
children?: Snippet
|
||||
}
|
||||
let { href, children }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} target="_blank" rel="noopener noreferrer">
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -1,29 +1,24 @@
|
||||
<script lang="ts">
|
||||
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
|
||||
import { getContext, type Snippet } from 'svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowCopilotContext } from '../../flow'
|
||||
import type { FlowEditorContext } from '$lib/components/flows/types'
|
||||
import { dfs } from '$lib/components/flows/previousResults'
|
||||
import { getSubModules } from '$lib/components/flows/flowExplorer'
|
||||
import AIChat from '../AIChat.svelte'
|
||||
import type { FlowModule, OpenFlow, ScriptLang } from '$lib/gen'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { getIndexInNestedModules, getNestedModules } from './utils'
|
||||
import type { FlowModuleState } from '$lib/components/flows/flowState'
|
||||
import { getStringError } from '../utils'
|
||||
import type { FlowAIChatHelpers } from './core'
|
||||
import {
|
||||
insertNewFailureModule,
|
||||
insertNewPreprocessorModule
|
||||
} from '$lib/components/flows/flowStateUtils'
|
||||
import type { ScriptOptions } from '../ContextManager.svelte'
|
||||
import { loadSchemaFromModule } from '$lib/components/flows/flowInfers'
|
||||
import { aiChatManager } from '../AIChatManager.svelte'
|
||||
|
||||
let {
|
||||
flowModuleSchemaMap,
|
||||
headerLeft
|
||||
flowModuleSchemaMap
|
||||
}: {
|
||||
flowModuleSchemaMap: FlowModuleSchemaMap | undefined
|
||||
headerLeft: Snippet
|
||||
} = $props()
|
||||
|
||||
const { flowStore, flowStateStore, selectedId, currentEditor, flowInputsStore } =
|
||||
@@ -41,46 +36,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getScriptOptions(id: string): ScriptOptions | undefined {
|
||||
const module = getModule(id)
|
||||
|
||||
if (module && module.value.type === 'rawscript') {
|
||||
const moduleState: FlowModuleState | undefined = $flowStateStore[module.id]
|
||||
|
||||
const editorRelated =
|
||||
$currentEditor && $currentEditor.type === 'script' && $currentEditor.stepId === module.id
|
||||
? {
|
||||
diffMode: $currentEditor.diffMode,
|
||||
lastDeployedCode: $currentEditor.lastDeployedCode,
|
||||
lastSavedCode: undefined
|
||||
}
|
||||
: {
|
||||
diffMode: false,
|
||||
lastDeployedCode: undefined,
|
||||
lastSavedCode: undefined
|
||||
}
|
||||
|
||||
return {
|
||||
args: moduleState?.previewArgs ?? {},
|
||||
error:
|
||||
moduleState && !moduleState.previewSuccess
|
||||
? getStringError(moduleState.previewResult)
|
||||
: undefined,
|
||||
code: module.value.content,
|
||||
lang: module.value.language,
|
||||
path: module.id,
|
||||
...editorRelated
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
let scriptOptions = $derived.by(() => getScriptOptions($selectedId))
|
||||
|
||||
const flowHelpers: FlowAIChatHelpers & {
|
||||
getFlowAndSelectedId: () => { flow: OpenFlow; selectedId: string }
|
||||
} = {
|
||||
const flowHelpers: FlowAIChatHelpers = {
|
||||
getFlowAndSelectedId: () => ({ flow: $flowStore, selectedId: $selectedId }),
|
||||
setCode: async (id, code) => {
|
||||
const module = getModule(id)
|
||||
@@ -378,37 +334,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
let aiChat: AIChat | undefined = undefined
|
||||
$effect(() => {
|
||||
const cleanup = aiChatManager.setFlowHelpers(flowHelpers)
|
||||
return cleanup
|
||||
})
|
||||
|
||||
export async function generateStep(moduleId: string, lang: ScriptLang, instructions: string) {
|
||||
flowHelpers.selectStep(moduleId)
|
||||
aiChat?.sendRequest({
|
||||
instructions: instructions,
|
||||
mode: 'script',
|
||||
lang: lang,
|
||||
isPreprocessor: moduleId === 'preprocessor'
|
||||
})
|
||||
}
|
||||
$effect(() => {
|
||||
const cleanup = aiChatManager.listenForSelectedIdChanges(
|
||||
$selectedId,
|
||||
$flowStore,
|
||||
$flowStateStore,
|
||||
$currentEditor
|
||||
)
|
||||
return cleanup
|
||||
})
|
||||
|
||||
export function addSelectedLinesToContext(lines: string, startLine: number, endLine: number) {
|
||||
aiChat?.addSelectedLinesToContext(lines, startLine, endLine)
|
||||
}
|
||||
$effect(() => {
|
||||
const cleanup = aiChatManager.listenForCurrentEditorChanges($currentEditor)
|
||||
return cleanup
|
||||
})
|
||||
</script>
|
||||
|
||||
<AIChat
|
||||
bind:this={aiChat}
|
||||
{headerLeft}
|
||||
{scriptOptions}
|
||||
{flowHelpers}
|
||||
showDiffMode={() => {
|
||||
if ($currentEditor && $currentEditor.type === 'script') {
|
||||
$currentEditor.showDiffMode()
|
||||
}
|
||||
}}
|
||||
applyCode={(code: string) => {
|
||||
if ($currentEditor && $currentEditor.type === 'script') {
|
||||
$currentEditor.hideDiffMode()
|
||||
$currentEditor.editor.reviewAndApplyCode(code)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script lang="ts">
|
||||
import autosize from '$lib/autosize'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Loader2, PlusIcon } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ProviderModelSelector from '../ProviderModelSelector.svelte'
|
||||
import type { AIChatContext, DisplayMessage } from '../shared'
|
||||
import { getContext } from 'svelte'
|
||||
import AssistantMessage from '../AssistantMessage.svelte'
|
||||
|
||||
let {
|
||||
messages,
|
||||
instructions = $bindable(),
|
||||
sendRequest,
|
||||
clear
|
||||
}: {
|
||||
messages: DisplayMessage[]
|
||||
instructions: string
|
||||
sendRequest: () => void
|
||||
clear: () => void
|
||||
} = $props()
|
||||
|
||||
let scrollEl: HTMLDivElement | undefined = $state()
|
||||
let automaticScroll = $state(true)
|
||||
|
||||
export function enableAutomaticScroll() {
|
||||
automaticScroll = true
|
||||
}
|
||||
async function scrollDown() {
|
||||
scrollEl?.scrollTo({
|
||||
top: scrollEl.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
let height = $state(0)
|
||||
$effect(() => {
|
||||
automaticScroll && height && scrollDown()
|
||||
})
|
||||
|
||||
const { loading, currentReply } = getContext<AIChatContext>('AIChatContext')
|
||||
|
||||
export function focusInput() {}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-2 p-2 border-b border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<p class="text-sm font-semibold">Chat</p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
title="New chat"
|
||||
on:click={() => {
|
||||
clear()
|
||||
}}
|
||||
size="md"
|
||||
btnClasses="!p-1"
|
||||
startIcon={{ icon: PlusIcon }}
|
||||
iconOnly
|
||||
variant="border"
|
||||
color="light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if messages.length > 0}
|
||||
<div
|
||||
class="h-full overflow-y-scroll pt-2"
|
||||
bind:this={scrollEl}
|
||||
onwheel={(e) => {
|
||||
automaticScroll = false
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col" bind:clientHeight={height}>
|
||||
{#each messages as message}
|
||||
<div
|
||||
class={twMerge(
|
||||
'text-sm py-1 mx-2',
|
||||
message.role === 'user' &&
|
||||
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg mb-2',
|
||||
(message.role === 'assistant' || message.role === 'tool') && 'px-[1px]',
|
||||
message.role === 'tool' && 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
{#if message.role === 'assistant'}
|
||||
<AssistantMessage {message} />
|
||||
{:else}
|
||||
{message.content}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if $loading && !$currentReply}
|
||||
<div class="mb-6 py-1 px-2">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class:border-t={messages.length > 0}>
|
||||
<div class="relative w-full px-2 scroll-pb-2 pt-2">
|
||||
<textarea
|
||||
bind:value={instructions}
|
||||
use:autosize
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendRequest()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={messages.length === 0 ? 'Ask anything' : 'Ask followup'}
|
||||
class="resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class={`flex flex-row justify-end items-center gap-2 px-0.5`}>
|
||||
<ProviderModelSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,15 @@
|
||||
import { ScriptService, type FlowModule, type RawScript, type Script } from '$lib/gen'
|
||||
import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs'
|
||||
import {
|
||||
ScriptService,
|
||||
type FlowModule,
|
||||
type OpenFlow,
|
||||
type RawScript,
|
||||
type Script
|
||||
} from '$lib/gen'
|
||||
import type {
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionTool,
|
||||
ChatCompletionUserMessageParam
|
||||
} from 'openai/resources/chat/completions.mjs'
|
||||
import YAML from 'yaml'
|
||||
import { z } from 'zod'
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema'
|
||||
@@ -15,6 +25,7 @@ import type { Tool } from '../shared'
|
||||
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
|
||||
export interface FlowAIChatHelpers {
|
||||
getFlowAndSelectedId: () => { flow: OpenFlow; selectedId: string }
|
||||
insertStep: (location: InsertLocation, step: NewStep) => Promise<string>
|
||||
removeStep: (id: string) => Promise<void>
|
||||
getStepInputs: (id: string) => Promise<Record<string, any>>
|
||||
@@ -562,11 +573,8 @@ function createToolDef(
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareFlowSystemMessage(): {
|
||||
role: 'system'
|
||||
content: string
|
||||
} {
|
||||
const content = `You are a helpful assitant that creates and edit workflows on the Windmill platform. You're provided with a a bunch of tools to help you edit the flow.
|
||||
export function prepareFlowSystemMessage(): ChatCompletionSystemMessageParam {
|
||||
const content = `You are a helpful assistant that creates and edits workflows on the Windmill platform. You're provided with a bunch of tools to help you edit the flow.
|
||||
Follow the user instructions carefully.
|
||||
Go step by step, and explain what you're doing as you're doing it.
|
||||
DO NOT wait for user confirmation before performing an action. Only do it if the user explicitly asks you to wait in their initial instructions.
|
||||
@@ -630,10 +638,21 @@ If the user wants a specific resource as step input, you should set the step val
|
||||
|
||||
export function prepareFlowUserMessage(
|
||||
instructions: string,
|
||||
flowAndSelectedId: { flow: ExtendedOpenFlow; selectedId: string }
|
||||
) {
|
||||
const { flow, selectedId } = flowAndSelectedId
|
||||
return `## FLOW:
|
||||
flowAndSelectedId?: { flow: ExtendedOpenFlow; selectedId: string }
|
||||
): ChatCompletionUserMessageParam {
|
||||
const flow = flowAndSelectedId?.flow
|
||||
const selectedId = flowAndSelectedId?.selectedId
|
||||
|
||||
if (!flow || !selectedId) {
|
||||
return {
|
||||
role: 'user',
|
||||
content: `## INSTRUCTIONS:
|
||||
${instructions}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: 'user',
|
||||
content: `## FLOW:
|
||||
flow_input schema:
|
||||
${JSON.stringify(flow.schema ?? emptySchema())}
|
||||
|
||||
@@ -651,4 +670,5 @@ ${selectedId}
|
||||
|
||||
## INSTRUCTIONS:
|
||||
${instructions}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { aiChatManager } from '../AIChatManager.svelte'
|
||||
import AiChat from '../AIChat.svelte'
|
||||
import HideButton from '../../../apps/editor/settingsPanel/HideButton.svelte'
|
||||
</script>
|
||||
|
||||
<div class="relative flex flex-col h-full bg-surface z-20">
|
||||
<AiChat headerLeft={aiChatHeaderLeft} />
|
||||
</div>
|
||||
|
||||
{#snippet aiChatHeaderLeft()}
|
||||
<HideButton
|
||||
hidden={false}
|
||||
direction="right"
|
||||
panelName="AI"
|
||||
shortcut="L"
|
||||
size="md"
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { page } from '$app/state'
|
||||
import type {
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionTool,
|
||||
ChatCompletionUserMessageParam
|
||||
} from 'openai/resources/index.mjs'
|
||||
import type { Tool } from '../shared'
|
||||
import { aiChatManager } from '../AIChatManager.svelte'
|
||||
|
||||
export const CHAT_SYSTEM_PROMPT = `
|
||||
You are Windmill's intelligent assistant, designed to help users navigate the application and answer questions about its functionality. It is your only purpose to help the user in the context of the windmill application.
|
||||
Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications.
|
||||
|
||||
You have access to these tools:
|
||||
1. View current buttons and inputs on the page (get_triggerable_components)
|
||||
2. Execute buttons and inputs (trigger_component)
|
||||
3. Get documentation for user requests (get_documentation)
|
||||
|
||||
INSTRUCTIONS:
|
||||
- When users ask about application features or concepts, first use get_documentation internally to retrieve accurate information about how to fulfill the user's request.
|
||||
- Then immediately use the available tools to guide the user through the application. Do not wait for the user's confirmation before taking action.
|
||||
- If you detect a confirmation modal that needs user confirmation, stop the navigation and let the user know that the action is pending confirmation.
|
||||
- Use get_triggerable_components to understand available options, and then trigger the components using trigger_component. Then wait a moment before rescanning the current page, and then continue with the next step. Do this 5 times max.
|
||||
- Make sure you navigated as far as possible before responding to the user. Always use get_triggerable_components one last time to make sure you didn't miss anything.
|
||||
- If you are not able to fulfill the user's request after 5 attempts, redirect the user to the documentation.
|
||||
|
||||
GENERAL PRINCIPLES:
|
||||
- Be concise but thorough
|
||||
- Focus on taking action and completing the user's goals
|
||||
- Maintain a friendly, professional tone
|
||||
- If you encounter an error or can't complete a request, explain why and suggest alternatives
|
||||
- When asked about a specific script, flow or app, first check components directly related to the mentioned entity, before checking the other components.
|
||||
- When you do not find what you are looking for on the current page, go to the home page by looking for the "Home" component, then scan the components again.
|
||||
|
||||
IMPORTANT CONSIDERATIONS:
|
||||
- If you navigate to a script creation page, consider this:
|
||||
- The page opens with the settings drawer open. After doing the changes mentioned by the user, close the settings drawer.
|
||||
- Then if the user has described what he wanted the script to do, switch to script mode with the change_mode tool, and use the new tools you'll have access to to edit the script.
|
||||
- If you navigate to a flow creation page, consider this:
|
||||
- If the user has described what he wanted the flow to do, switch to flow mode with the change_mode tool before using the new tools you'll have access to to edit the flow.
|
||||
|
||||
Always use the provided tools purposefully and appropriately to achieve the user's goals.
|
||||
Your actions only allow you to navigate the application through the provided tools.
|
||||
When you complete the user's request, do not say "I created..." or "I updated..." or "I deleted...", but rather complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.
|
||||
|
||||
Example of good behavior:
|
||||
- User: "How can I set my AI providers?"
|
||||
- You: <call get_documentation and fetch relevant documentation>
|
||||
- You: <call get_triggerable_components to find relevant components>
|
||||
- You: <trigger the components>
|
||||
- You: "<precisions about the request based on the documentation>"
|
||||
`
|
||||
|
||||
const GET_DOCUMENTATION_TOOL: ChatCompletionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_documentation',
|
||||
description: 'Get the documentation for the user request',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
request: {
|
||||
type: 'string',
|
||||
description: 'The user request'
|
||||
}
|
||||
},
|
||||
required: ['request']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool definitions
|
||||
const GET_TRIGGERABLE_COMPONENTS_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'
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Description of the component'
|
||||
}
|
||||
},
|
||||
required: ['id', 'description']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const GET_CURRENT_PAGE_NAME_TOOL: ChatCompletionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_current_page_name',
|
||||
description: 'Get the name of the current page the user is on.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTriggerableComponents(): string {
|
||||
try {
|
||||
// Get components registered in the triggerablesByAI store
|
||||
const registeredComponents = aiChatManager.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}" - Description: ${component.description} - Triggerable: ${component.onTrigger ? 'Yes' : 'No'}\n`
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error getting triggerable components:', error)
|
||||
return 'Error getting triggerable components: ' + error.message
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get the current page name
|
||||
function getCurrentPageName(): string {
|
||||
try {
|
||||
const currentPage = page.url.pathname
|
||||
switch (currentPage) {
|
||||
case '/':
|
||||
return 'Home Page'
|
||||
case '/flows/add':
|
||||
return 'Flow creation page'
|
||||
case '/scripts/add':
|
||||
return 'Script creation page'
|
||||
case '/apps/add':
|
||||
return 'App creation page'
|
||||
default:
|
||||
return 'Non-specific page'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting current page name:', error)
|
||||
return 'Error getting current page name: ' + 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 component = aiChatManager.triggerablesByAI[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}`
|
||||
}
|
||||
}
|
||||
|
||||
async function getDocumentation(args: { request: string }): Promise<string> {
|
||||
const retrieval = await fetch('/api/inkeep', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'inkeep-rag',
|
||||
messages: [{ role: 'user', content: args.request }],
|
||||
response_format: {
|
||||
type: 'json_object'
|
||||
}
|
||||
})
|
||||
})
|
||||
const data = await retrieval.json()
|
||||
if (!data.choices?.[0]?.message?.content) {
|
||||
return 'No documentation found for this request'
|
||||
}
|
||||
|
||||
// Parse the raw response
|
||||
const raw = data.choices[0].message.content
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
// Clean up the response to include only essential information
|
||||
if (parsed.content && Array.isArray(parsed.content)) {
|
||||
const cleanedContent = parsed.content.map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
content: item.source?.content.map((c: any) => c.text).join('\n') || []
|
||||
}))
|
||||
// Limit the response to 30000 characters max
|
||||
const stringified = JSON.stringify({ content: cleanedContent }).slice(0, 30000)
|
||||
|
||||
return stringified
|
||||
}
|
||||
|
||||
return data.choices[0].message.content
|
||||
}
|
||||
|
||||
export const navigatorTools: Tool<{}>[] = [
|
||||
{
|
||||
def: GET_TRIGGERABLE_COMPONENTS_TOOL,
|
||||
fn: async ({ toolId, toolCallbacks }) => {
|
||||
toolCallbacks.onToolCall(toolId, 'Looking for screen components...')
|
||||
const components = getTriggerableComponents()
|
||||
toolCallbacks.onFinishToolCall(toolId, 'Retrieved screen components')
|
||||
return components
|
||||
}
|
||||
},
|
||||
{
|
||||
def: EXECUTE_COMMAND_TOOL,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.onToolCall(toolId, 'Clicking on component...')
|
||||
const result = triggerComponent(args)
|
||||
toolCallbacks.onFinishToolCall(
|
||||
toolId,
|
||||
'Clicked ' + args.description.charAt(0).toLowerCase() + args.description.slice(1)
|
||||
)
|
||||
return result
|
||||
}
|
||||
},
|
||||
{
|
||||
def: GET_DOCUMENTATION_TOOL,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.onToolCall(toolId, 'Getting documentation...')
|
||||
try {
|
||||
const docResult = await getDocumentation(args)
|
||||
toolCallbacks.onFinishToolCall(toolId, 'Retrieved documentation')
|
||||
return docResult
|
||||
} catch (error) {
|
||||
toolCallbacks.onFinishToolCall(toolId, 'Failed to get documentation')
|
||||
console.error('Error getting documentation:', error)
|
||||
return 'Failed to get documentation, pursuing with the user request...'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
def: GET_CURRENT_PAGE_NAME_TOOL,
|
||||
fn: async ({ toolId, toolCallbacks }) => {
|
||||
const pageName = getCurrentPageName()
|
||||
toolCallbacks.onFinishToolCall(toolId, 'Retrieved current page name')
|
||||
return pageName
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export function prepareNavigatorSystemMessage(): ChatCompletionSystemMessageParam {
|
||||
return {
|
||||
role: 'system',
|
||||
content: CHAT_SYSTEM_PROMPT
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareNavigatorUserMessage(instructions: string): ChatCompletionUserMessageParam {
|
||||
return {
|
||||
role: 'user',
|
||||
content: instructions
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { initializeVscode } from '$lib/components/vscode'
|
||||
import type { AIChatContext, DisplayMessage } from '../shared'
|
||||
import type { DisplayMessage } from '../shared'
|
||||
import type { ContextElement } from '../context'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import {
|
||||
@@ -22,16 +22,10 @@
|
||||
yaml
|
||||
} from 'svelte-highlight/languages'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
import { aiChatManager } from '../AIChatManager.svelte'
|
||||
|
||||
const astNode = getAstNode()
|
||||
|
||||
const {
|
||||
loading: loadingContext,
|
||||
currentReply,
|
||||
applyCode,
|
||||
canApplyCode
|
||||
} = getContext<AIChatContext>('AIChatContext')
|
||||
|
||||
const { message } = getContext<{ message: DisplayMessage }>('AssistantMessageContext')
|
||||
|
||||
let codeContext = $derived(
|
||||
@@ -107,8 +101,11 @@
|
||||
let loading = $state(true)
|
||||
$effect(() => {
|
||||
// we only want to trigger when astNode offset is updated not currentReply, otherwise as there is some delay on the offset update, loading would be set to false too early
|
||||
const completeReply = untrack(() => $currentReply)
|
||||
if (!$loadingContext || completeReply.length > (astNode.current.position?.end.offset ?? 0)) {
|
||||
const completeReply = untrack(() => aiChatManager.currentReply)
|
||||
if (
|
||||
!aiChatManager.loading ||
|
||||
completeReply.length > (astNode.current.position?.end.offset ?? 0)
|
||||
) {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
@@ -183,13 +180,13 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-0.5 rounded-lg relative not-prose">
|
||||
{#if canApplyCode()}
|
||||
{#if aiChatManager.canApplyCode}
|
||||
<div class="flex justify-end items-end">
|
||||
<Button
|
||||
color="dark"
|
||||
size="xs2"
|
||||
on:click={() => {
|
||||
applyCode(code ?? '')
|
||||
aiChatManager.scriptEditorApplyCode?.(code ?? '')
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
@@ -200,17 +197,17 @@
|
||||
<div
|
||||
class="relative w-full border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
|
||||
>
|
||||
{#if (loading && !code) || !language}
|
||||
{#if aiChatManager.mode !== 'navigator' && ((loading && !code) || !language)}
|
||||
<div class="flex flex-row gap-1 p-2 items-center justify-center">
|
||||
<Loader2 class="w-4 h-4 animate-spin" /> Generating code...
|
||||
</div>
|
||||
{:else if !loading && codeContext && getSmartLang(codeContext.lang) === getSmartLang(language)}
|
||||
{:else if !loading && codeContext && getSmartLang(codeContext.lang) === getSmartLang(language as string)}
|
||||
<div bind:this={diffEl} class="w-full h-full"></div>
|
||||
{:else}
|
||||
<HighlightCode
|
||||
class="p-1"
|
||||
code={code ?? ''}
|
||||
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language)]}
|
||||
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
|
||||
language={undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { ResourceType, ScriptLang } from '$lib/gen/types.gen'
|
||||
import { capitalize, isObject, toCamel } from '$lib/utils'
|
||||
import { get } from 'svelte/store'
|
||||
import { compile, phpCompile, pythonCompile } from '../../utils'
|
||||
import type { ChatCompletionTool } from 'openai/resources/index.mjs'
|
||||
import type {
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionTool,
|
||||
ChatCompletionUserMessageParam
|
||||
} from 'openai/resources/index.mjs'
|
||||
import { type DBSchema, dbSchemas } from '$lib/stores'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/utils'
|
||||
@@ -279,10 +283,7 @@ WINDMILL LANGUAGE CONTEXT:
|
||||
|
||||
export const CHAT_USER_DB_CONTEXT = `- {title}: SCHEMA: \n{schema}\n`
|
||||
|
||||
export function prepareScriptSystemMessage(): {
|
||||
role: 'system'
|
||||
content: string
|
||||
} {
|
||||
export function prepareScriptSystemMessage(): ChatCompletionSystemMessageParam {
|
||||
return {
|
||||
role: 'system',
|
||||
content: CHAT_SYSTEM_PROMPT
|
||||
@@ -301,6 +302,20 @@ const applyCodePieceToCodeContext = (codePieces: CodePieceElement[], codeContext
|
||||
return code.join('\n')
|
||||
}
|
||||
|
||||
export function prepareScriptTools(
|
||||
language: ScriptLang | 'bunnative',
|
||||
context: ContextElement[]
|
||||
): Tool<ScriptChatHelpers>[] {
|
||||
const tools: Tool<ScriptChatHelpers>[] = [resourceTypeTool, dbSchemaTool]
|
||||
if (['python3', 'php', 'bun', 'deno', 'nativets', 'bunnative'].includes(language)) {
|
||||
tools.push(resourceTypeTool)
|
||||
}
|
||||
if (context.some((c) => c.type === 'db')) {
|
||||
tools.push(dbSchemaTool)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
export async function prepareScriptUserMessage(
|
||||
instructions: string,
|
||||
language: ScriptLang | 'bunnative',
|
||||
@@ -308,7 +323,7 @@ export async function prepareScriptUserMessage(
|
||||
options: {
|
||||
isPreprocessor?: boolean
|
||||
} = {}
|
||||
) {
|
||||
): Promise<ChatCompletionUserMessageParam> {
|
||||
let codeContext = 'CODE:\n'
|
||||
let errorContext = 'ERROR:\n'
|
||||
let dbContext = 'DATABASES:\n'
|
||||
@@ -364,7 +379,10 @@ export async function prepareScriptUserMessage(
|
||||
if (hasDiff) {
|
||||
userMessage += diffContext
|
||||
}
|
||||
return userMessage
|
||||
return {
|
||||
role: 'user',
|
||||
content: userMessage
|
||||
}
|
||||
}
|
||||
|
||||
const RESOURCE_TYPE_FUNCTION_DEF: ChatCompletionTool = {
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import type {
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionTool
|
||||
} from 'openai/resources/chat/completions.mjs'
|
||||
import { get, type Writable } from 'svelte/store'
|
||||
import { get } from 'svelte/store'
|
||||
import type { ContextElement } from './context'
|
||||
import { getCompletion } from '../lib'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
export interface AIChatContext {
|
||||
loading: Writable<boolean>
|
||||
currentReply: Writable<string>
|
||||
canApplyCode: () => boolean
|
||||
applyCode: (code: string) => void
|
||||
}
|
||||
|
||||
export type DisplayMessage =
|
||||
| {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -53,7 +43,7 @@ async function callTool<T>({
|
||||
return tool.fn({ args, workspace, helpers, toolCallbacks, toolId })
|
||||
}
|
||||
|
||||
async function processToolCall<T>({
|
||||
export async function processToolCall<T>({
|
||||
tools,
|
||||
toolCall,
|
||||
messages,
|
||||
@@ -109,102 +99,3 @@ export interface ToolCallbacks {
|
||||
onToolCall: (id: string, content: string) => void
|
||||
onFinishToolCall: (id: string, content: string) => void
|
||||
}
|
||||
|
||||
export async function chatRequest<T>({
|
||||
systemMessage,
|
||||
messages,
|
||||
abortController,
|
||||
tools,
|
||||
helpers,
|
||||
callbacks
|
||||
}: {
|
||||
systemMessage: ChatCompletionSystemMessageParam
|
||||
messages: ChatCompletionMessageParam[]
|
||||
abortController: AbortController
|
||||
tools: Tool<T>[]
|
||||
helpers: T
|
||||
callbacks: ToolCallbacks & {
|
||||
onNewToken: (token: string) => void
|
||||
onMessageEnd: () => void
|
||||
}
|
||||
}) {
|
||||
try {
|
||||
let completion: any = null
|
||||
while (true) {
|
||||
completion = await getCompletion(
|
||||
[systemMessage, ...messages],
|
||||
abortController,
|
||||
tools.map((t) => t.def)
|
||||
)
|
||||
|
||||
if (completion) {
|
||||
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
|
||||
|
||||
let answer = ''
|
||||
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) {
|
||||
answer += delta
|
||||
callbacks.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
messages.push({ role: 'assistant', content: answer })
|
||||
}
|
||||
|
||||
callbacks.onMessageEnd()
|
||||
|
||||
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.map((t) => ({
|
||||
...t,
|
||||
function: {
|
||||
...t.function,
|
||||
arguments: t.function.arguments || '{}'
|
||||
}
|
||||
}))
|
||||
})
|
||||
for (const toolCall of toolCalls) {
|
||||
await processToolCall({ tools, toolCall, messages, helpers, toolCallbacks: callbacks })
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
throw err
|
||||
} else {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
aiId="apps-create-actions-app"
|
||||
aiDescription="Create a new low-code app"
|
||||
size="sm"
|
||||
spacingSize="xl"
|
||||
startIcon={{ icon: Plus }}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
aiId="flows-create-actions-flow"
|
||||
aiDescription="Create a new flow"
|
||||
size="sm"
|
||||
spacingSize="xl"
|
||||
startIcon={{ icon: Plus }}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
|
||||
import WindmillIcon from '../icons/WindmillIcon.svelte'
|
||||
import { Skeleton } from '../common'
|
||||
import { getContext, setContext } from 'svelte'
|
||||
import { getContext, onDestroy, onMount, setContext } from 'svelte'
|
||||
import type { FlowEditorContext } from './types'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
@@ -13,8 +13,8 @@
|
||||
import type { Flow } from '$lib/gen'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
|
||||
import HideButton from '../apps/editor/settingsPanel/HideButton.svelte'
|
||||
import { chatMode, copilotInfo } from '$lib/stores'
|
||||
import { aiChatManager } from '../copilot/chat/AIChatManager.svelte'
|
||||
import TriggerableByAI from '../TriggerableByAI.svelte'
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
export let loading: boolean
|
||||
@@ -39,64 +39,30 @@
|
||||
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined)
|
||||
})
|
||||
|
||||
const DEFAULT_AI_PANEL_SIZE = 25
|
||||
const DEFAULT_MODULE_PANEL_SIZE = 35
|
||||
const DEFAULT_MAP_PANEL_SIZE = 40
|
||||
|
||||
let aiPanelSize =
|
||||
!$copilotInfo.enabled || localStorage.getItem('aiPanelOpen') === 'false'
|
||||
? 0
|
||||
: DEFAULT_AI_PANEL_SIZE
|
||||
let modulePanelSize = DEFAULT_MODULE_PANEL_SIZE + (DEFAULT_AI_PANEL_SIZE - aiPanelSize)
|
||||
let storedAiPanelSize = aiPanelSize > 0 ? aiPanelSize : DEFAULT_AI_PANEL_SIZE
|
||||
let mapPanelSize = DEFAULT_MAP_PANEL_SIZE
|
||||
|
||||
export function toggleAiPanel(mode?: 'script' | 'flow') {
|
||||
if (!$copilotInfo.enabled) return
|
||||
if (aiPanelSize > 0) {
|
||||
storedAiPanelSize = aiPanelSize
|
||||
modulePanelSize += aiPanelSize
|
||||
aiPanelSize = 0
|
||||
localStorage.setItem('aiPanelOpen', 'false')
|
||||
} else {
|
||||
modulePanelSize -= storedAiPanelSize
|
||||
aiPanelSize = storedAiPanelSize
|
||||
localStorage.setItem('aiPanelOpen', 'true')
|
||||
if (mode) {
|
||||
chatMode.set(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function addSelectedLinesToAiChat(lines: string, startLine: number, endLine: number) {
|
||||
flowAIChat?.addSelectedLinesToContext(lines, startLine, endLine)
|
||||
if (getIsAiPanelClosed()) {
|
||||
toggleAiPanel('script')
|
||||
aiChatManager.addSelectedLinesToContext(lines, startLine, endLine)
|
||||
if (!aiChatManager.open) {
|
||||
aiChatManager.openChat()
|
||||
aiChatManager.changeMode('script')
|
||||
}
|
||||
}
|
||||
|
||||
export function getIsAiPanelClosed() {
|
||||
return aiPanelSize === 0
|
||||
}
|
||||
onMount(() => {
|
||||
aiChatManager.changeMode('flow')
|
||||
})
|
||||
|
||||
let flowAIChat: FlowAIChat | undefined = undefined
|
||||
onDestroy(() => {
|
||||
aiChatManager.changeMode('navigator')
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
e.preventDefault()
|
||||
toggleAiPanel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="flow-editor"
|
||||
class={'h-full overflow-hidden transition-colors duration-[400ms] ease-linear border-t'}
|
||||
>
|
||||
<TriggerableByAI id="flow-editor" description="Component to edit a flow" />
|
||||
<Splitpanes>
|
||||
<Pane bind:size={mapPanelSize} minSize={15} class="h-full relative z-0">
|
||||
<Pane size={50} minSize={15} class="h-full relative z-0">
|
||||
<div class="grow overflow-hidden bg-gray h-full bg-surface-secondary relative">
|
||||
{#if loading}
|
||||
<div class="p-2 pt-10">
|
||||
@@ -116,16 +82,16 @@
|
||||
bind:modules={$flowStore.value.modules}
|
||||
on:reload
|
||||
on:generateStep={({ detail }) => {
|
||||
if (getIsAiPanelClosed()) {
|
||||
toggleAiPanel()
|
||||
if (!aiChatManager.open) {
|
||||
aiChatManager.openChat()
|
||||
}
|
||||
flowAIChat?.generateStep(detail.moduleId, detail.lang, detail.instructions)
|
||||
aiChatManager.generateStep(detail.moduleId, detail.lang, detail.instructions)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane class="relative z-10" bind:size={modulePanelSize} minSize={20}>
|
||||
<Pane class="relative z-10" size={50} minSize={20}>
|
||||
{#if loading}
|
||||
<div class="w-full h-full">
|
||||
<div class="block m-auto pt-40 w-10">
|
||||
@@ -144,22 +110,8 @@
|
||||
/>
|
||||
{/if}
|
||||
</Pane>
|
||||
{#if !disableAi && aiPanelSize > 0}
|
||||
{#snippet aiChatHeaderLeft()}
|
||||
<HideButton
|
||||
hidden={false}
|
||||
direction="right"
|
||||
panelName="AI"
|
||||
shortcut="L"
|
||||
size="md"
|
||||
on:click={() => {
|
||||
toggleAiPanel()
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
<Pane bind:size={aiPanelSize}>
|
||||
<FlowAIChat bind:this={flowAIChat} {flowModuleSchemaMap} headerLeft={aiChatHeaderLeft} />
|
||||
</Pane>
|
||||
{#if !disableAi}
|
||||
<FlowAIChat {flowModuleSchemaMap} />
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
@@ -41,24 +41,25 @@ export type FlowInputEditorState = {
|
||||
payloadData: Record<string, any> | undefined
|
||||
}
|
||||
|
||||
export type CurrentEditor =
|
||||
| ((
|
||||
| {
|
||||
type: 'script'
|
||||
editor: Editor
|
||||
showDiffMode: () => void
|
||||
hideDiffMode: () => void
|
||||
diffMode: boolean
|
||||
lastDeployedCode: string | undefined
|
||||
}
|
||||
| { type: 'iterator'; editor: SimpleEditor }
|
||||
) & {
|
||||
stepId: string
|
||||
})
|
||||
| undefined
|
||||
|
||||
export type FlowEditorContext = {
|
||||
selectedId: Writable<string>
|
||||
currentEditor: Writable<
|
||||
| ((
|
||||
| {
|
||||
type: 'script'
|
||||
editor: Editor
|
||||
showDiffMode: () => void
|
||||
hideDiffMode: () => void
|
||||
diffMode: boolean
|
||||
lastDeployedCode: string | undefined
|
||||
}
|
||||
| { type: 'iterator'; editor: SimpleEditor }
|
||||
) & {
|
||||
stepId: string
|
||||
})
|
||||
| undefined
|
||||
>
|
||||
currentEditor: Writable<CurrentEditor>
|
||||
moving: Writable<{ module: FlowModule; modules: FlowModule[] } | undefined>
|
||||
previewArgs: Writable<Record<string, any>>
|
||||
scriptEditorDrawer: Writable<ScriptEditorDrawer | undefined>
|
||||
|
||||
@@ -237,6 +237,8 @@
|
||||
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]}
|
||||
|
||||
<MeltButton
|
||||
aiId={`trigger-button-${type}`}
|
||||
aiDescription={`Trigger button for ${type}`}
|
||||
class={twMerge(
|
||||
'hover:bg-surface-hover rounded-md shadow-sm text-xs relative center-center cursor-pointer bg-slate-100 dark:bg-slate-700',
|
||||
'dark:outline dark:outline-1 outline-tertiary/20 group',
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import TreeViewRoot from './TreeViewRoot.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import TriggerableByAI from '../TriggerableByAI.svelte'
|
||||
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
canWrite: boolean
|
||||
@@ -334,217 +335,219 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<CenteredPage>
|
||||
<div class="flex flex-wrap gap-2 items-center justify-between w-full mt-2">
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={itemKind}
|
||||
on:selected={() => {
|
||||
if (itemKind != 'all') {
|
||||
subtab = itemKind
|
||||
}
|
||||
setQuery($page.url, 'kind', itemKind)
|
||||
}}
|
||||
class="h-10"
|
||||
let:item
|
||||
>
|
||||
<ToggleButton value="all" label="All" class="text-sm px-4 py-2" {item} />
|
||||
<ToggleButton
|
||||
value="script"
|
||||
icon={Code2}
|
||||
label="Scripts"
|
||||
class="text-sm px-4 py-2"
|
||||
{item}
|
||||
/>
|
||||
{#if HOME_SEARCH_SHOW_FLOW}
|
||||
<TriggerableByAI id="home-items-list" description="Lists of scripts, flows, and apps">
|
||||
<CenteredPage>
|
||||
<div class="flex flex-wrap gap-2 items-center justify-between w-full mt-2">
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={itemKind}
|
||||
on:selected={() => {
|
||||
if (itemKind != 'all') {
|
||||
subtab = itemKind
|
||||
}
|
||||
setQuery($page.url, 'kind', itemKind)
|
||||
}}
|
||||
class="h-10"
|
||||
let:item
|
||||
>
|
||||
<ToggleButton value="all" label="All" class="text-sm px-4 py-2" {item} />
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
value="script"
|
||||
icon={Code2}
|
||||
label="Scripts"
|
||||
class="text-sm px-4 py-2"
|
||||
selectedColor="#14b8a6"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
<ToggleButton
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
class="text-sm px-4 py-2"
|
||||
selectedColor="#fb923c"
|
||||
{item}
|
||||
/>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="relative text-tertiary grow min-w-[100px]">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
placeholder={HOME_SEARCH_PLACEHOLDER}
|
||||
bind:value={filter}
|
||||
class="bg-surface !h-10 !px-4 !pr-10 !rounded-lg text-sm focus:outline-none"
|
||||
/>
|
||||
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-3 mr-4">
|
||||
<svg
|
||||
class="h-4 w-4 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 56.966 56.966"
|
||||
style="enable-background:new 0 0 56.966 56.966;"
|
||||
xml:space="preserve"
|
||||
width="512px"
|
||||
height="512px"
|
||||
>
|
||||
<path
|
||||
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
on:click={() => openSearchWithPrefilledText('#')}
|
||||
variant="border"
|
||||
size="sm"
|
||||
spacingSize="lg"
|
||||
wrapperClasses="h-10"
|
||||
color="light"
|
||||
endIcon={{
|
||||
icon: SearchCode
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<ListFilters
|
||||
syncQuery
|
||||
bind:selectedFilter={ownerFilter}
|
||||
filters={owners}
|
||||
bottomMargin={false}
|
||||
/>
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
{#if !loading}
|
||||
<div class="flex w-full flex-row-reverse gap-2 mt-4 mb-1 items-center h-6">
|
||||
<Popover floatingConfig={{ placement: 'bottom-end' }}>
|
||||
<svelte:fragment slot="trigger">
|
||||
<Button
|
||||
startIcon={{
|
||||
icon: SlidersHorizontal
|
||||
}}
|
||||
nonCaptureEvent
|
||||
iconOnly
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
spacingSize="xs2"
|
||||
{#if HOME_SEARCH_SHOW_FLOW}
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
class="text-sm px-4 py-2"
|
||||
selectedColor="#14b8a6"
|
||||
{item}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content">
|
||||
<div class="p-4">
|
||||
<span class="text-sm font-semibold">Filters</span>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<Toggle size="xs" bind:checked={archived} options={{ right: 'Only archived' }} />
|
||||
{#if $userStore && !$userStore.operator}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={includeWithoutMain}
|
||||
options={{ right: 'Include without main function' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
|
||||
{#if treeView}
|
||||
<Button
|
||||
btnClasses="py-0 h-6"
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
startIcon={{
|
||||
icon: collapseAll ? UnfoldVertical : FoldVertical
|
||||
}}
|
||||
>
|
||||
{#if collapseAll}
|
||||
Expand all
|
||||
{:else}
|
||||
Collapse all
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if filteredItems == undefined}
|
||||
<div class="mt-4"></div>
|
||||
<Skeleton layout={[[2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
{:else if filteredItems.length === 0}
|
||||
<NoItemFound />
|
||||
{:else if treeView}
|
||||
<TreeViewRoot
|
||||
{items}
|
||||
{nbDisplayed}
|
||||
{collapseAll}
|
||||
isSearching={filter !== ''}
|
||||
on:scriptChanged={() => loadScripts(includeWithoutMain)}
|
||||
on:flowChanged={loadFlows}
|
||||
on:appChanged={loadApps}
|
||||
on:rawAppChanged={loadRawApps}
|
||||
on:reload={() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
}}
|
||||
{showCode}
|
||||
/>
|
||||
{:else}
|
||||
<div class="border rounded-md">
|
||||
{#each (items ?? []).slice(0, nbDisplayed) as item (item.type + '/' + item.path)}
|
||||
<Item
|
||||
{/if}
|
||||
<ToggleButton
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
class="text-sm px-4 py-2"
|
||||
selectedColor="#fb923c"
|
||||
{item}
|
||||
on:scriptChanged={() => loadScripts(includeWithoutMain)}
|
||||
on:flowChanged={loadFlows}
|
||||
on:appChanged={loadApps}
|
||||
on:rawAppChanged={loadRawApps}
|
||||
on:reload={() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
}}
|
||||
{showCode}
|
||||
/>
|
||||
{/each}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" on:click={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
|
||||
<div class="relative text-tertiary grow min-w-[100px]">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
placeholder={HOME_SEARCH_PLACEHOLDER}
|
||||
bind:value={filter}
|
||||
class="bg-surface !h-10 !px-4 !pr-10 !rounded-lg text-sm focus:outline-none"
|
||||
/>
|
||||
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-3 mr-4">
|
||||
<svg
|
||||
class="h-4 w-4 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 56.966 56.966"
|
||||
style="enable-background:new 0 0 56.966 56.966;"
|
||||
xml:space="preserve"
|
||||
width="512px"
|
||||
height="512px"
|
||||
>
|
||||
<path
|
||||
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
on:click={() => openSearchWithPrefilledText('#')}
|
||||
variant="border"
|
||||
size="sm"
|
||||
spacingSize="lg"
|
||||
wrapperClasses="h-10"
|
||||
color="light"
|
||||
endIcon={{
|
||||
icon: SearchCode
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<ListFilters
|
||||
syncQuery
|
||||
bind:selectedFilter={ownerFilter}
|
||||
filters={owners}
|
||||
bottomMargin={false}
|
||||
/>
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</CenteredPage>
|
||||
{#if !loading}
|
||||
<div class="flex w-full flex-row-reverse gap-2 mt-4 mb-1 items-center h-6">
|
||||
<Popover floatingConfig={{ placement: 'bottom-end' }}>
|
||||
<svelte:fragment slot="trigger">
|
||||
<Button
|
||||
startIcon={{
|
||||
icon: SlidersHorizontal
|
||||
}}
|
||||
nonCaptureEvent
|
||||
iconOnly
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
spacingSize="xs2"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content">
|
||||
<div class="p-4">
|
||||
<span class="text-sm font-semibold">Filters</span>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<Toggle size="xs" bind:checked={archived} options={{ right: 'Only archived' }} />
|
||||
{#if $userStore && !$userStore.operator}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={includeWithoutMain}
|
||||
options={{ right: 'Include without main function' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
|
||||
{#if treeView}
|
||||
<Button
|
||||
btnClasses="py-0 h-6"
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
startIcon={{
|
||||
icon: collapseAll ? UnfoldVertical : FoldVertical
|
||||
}}
|
||||
>
|
||||
{#if collapseAll}
|
||||
Expand all
|
||||
{:else}
|
||||
Collapse all
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if filteredItems == undefined}
|
||||
<div class="mt-4"></div>
|
||||
<Skeleton layout={[[2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
{:else if filteredItems.length === 0}
|
||||
<NoItemFound />
|
||||
{:else if treeView}
|
||||
<TreeViewRoot
|
||||
{items}
|
||||
{nbDisplayed}
|
||||
{collapseAll}
|
||||
isSearching={filter !== ''}
|
||||
on:scriptChanged={() => loadScripts(includeWithoutMain)}
|
||||
on:flowChanged={loadFlows}
|
||||
on:appChanged={loadApps}
|
||||
on:rawAppChanged={loadRawApps}
|
||||
on:reload={() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
}}
|
||||
{showCode}
|
||||
/>
|
||||
{:else}
|
||||
<div class="border rounded-md">
|
||||
{#each (items ?? []).slice(0, nbDisplayed) as item (item.type + '/' + item.path)}
|
||||
<Item
|
||||
{item}
|
||||
on:scriptChanged={() => loadScripts(includeWithoutMain)}
|
||||
on:flowChanged={loadFlows}
|
||||
on:appChanged={loadApps}
|
||||
on:rawAppChanged={loadRawApps}
|
||||
on:reload={() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
}}
|
||||
{showCode}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if items && items?.length > 15 && nbDisplayed < items.length}
|
||||
<span class="text-xs"
|
||||
>{nbDisplayed} items out of {items.length}
|
||||
<button class="ml-4" on:click={() => (nbDisplayed += 30)}>load 30 more</button></span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</CenteredPage>
|
||||
</TriggerableByAI>
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { type AnyMeltElement } from '@melt-ui/svelte'
|
||||
import { conditionalMelt } from '$lib/utils'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let meltElement: AnyMeltElement | undefined = undefined
|
||||
export let type: 'button' | 'submit' | 'reset' | null | undefined = undefined
|
||||
export let title: string = ''
|
||||
export let id: string | undefined = undefined
|
||||
|
||||
let buttonRef: HTMLButtonElement | undefined = undefined
|
||||
</script>
|
||||
|
||||
<button
|
||||
use:conditionalMelt={meltElement}
|
||||
class={$$props.class}
|
||||
{type}
|
||||
{title}
|
||||
{id}
|
||||
{...$meltElement}
|
||||
on:click
|
||||
<TriggerableByAI
|
||||
id={aiId}
|
||||
description={aiDescription}
|
||||
onTrigger={() => {
|
||||
buttonRef?.click()
|
||||
}}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
use:conditionalMelt={meltElement}
|
||||
class={$$props.class}
|
||||
{type}
|
||||
{title}
|
||||
{id}
|
||||
{...$meltElement}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</TriggerableByAI>
|
||||
|
||||
@@ -1,37 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { melt } from '@melt-ui/svelte'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let href: string | undefined = undefined
|
||||
export let disabled: boolean = false
|
||||
export let target: string | undefined = undefined
|
||||
export let item: MenubarMenuElements['item']
|
||||
|
||||
let aRef: HTMLAnchorElement | undefined = undefined
|
||||
let buttonRef: HTMLButtonElement | undefined = undefined
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
use:melt={$item}
|
||||
{href}
|
||||
class={$$props.class}
|
||||
role="menuitem"
|
||||
aria-disabled={disabled}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{target}
|
||||
on:m-focusin
|
||||
on:m-focusout
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
on:click
|
||||
use:melt={$item}
|
||||
{disabled}
|
||||
class={$$props.class}
|
||||
role="menuitem"
|
||||
on:m-focusin
|
||||
on:m-focusout
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
<TriggerableByAI
|
||||
id={aiId}
|
||||
description={aiDescription}
|
||||
onTrigger={() => {
|
||||
if (href) {
|
||||
aRef?.click()
|
||||
} else {
|
||||
buttonRef?.click()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={aRef}
|
||||
use:melt={$item}
|
||||
{href}
|
||||
class={$$props.class}
|
||||
role="menuitem"
|
||||
aria-disabled={disabled}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{target}
|
||||
on:m-focusin
|
||||
on:m-focusout
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
on:click
|
||||
use:melt={$item}
|
||||
{disabled}
|
||||
class={$$props.class}
|
||||
role="menuitem"
|
||||
on:m-focusin
|
||||
on:m-focusout
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
</TriggerableByAI>
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import { Code2, Plus } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
let { aiId, aiDescription } = $props<{ aiId: string; aiDescription: string }>()
|
||||
</script>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
{aiId}
|
||||
{aiDescription}
|
||||
size="sm"
|
||||
spacingSize="xl"
|
||||
color="marine"
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
type ListableApp,
|
||||
type ListableRawApp,
|
||||
type Script,
|
||||
|
||||
type SearchJobsIndexResponse
|
||||
|
||||
} from '$lib/gen'
|
||||
import { clickOutside, isMac } from '$lib/utils'
|
||||
import {
|
||||
@@ -44,6 +42,7 @@
|
||||
import Logs from 'lucide-svelte/icons/logs'
|
||||
import { AwsIcon, GoogleCloudIcon, KafkaIcon, MqttIcon, NatsIcon } from '../icons'
|
||||
import RunsSearch from './RunsSearch.svelte'
|
||||
import AskAiButton from '../copilot/AskAiButton.svelte'
|
||||
|
||||
let open: boolean = false
|
||||
|
||||
@@ -240,6 +239,7 @@
|
||||
let defaultMenuItemLabels = defaultMenuItems.map((item) => item.label)
|
||||
let defaultMenuItemAndHiddenLabels = defaultMenuItemsWithHidden.map((item) => item.label)
|
||||
let switchModeItemLabels = switchModeItems.map((item) => item.label)
|
||||
let askAiButton: AskAiButton | undefined
|
||||
|
||||
function fuzzyFilter(filter: string, items: any[], itemsPlainText: string[]) {
|
||||
if (filter === '') {
|
||||
@@ -264,7 +264,6 @@
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
let queryParseErrors: string[] = []
|
||||
|
||||
async function handleSearch() {
|
||||
@@ -320,8 +319,8 @@
|
||||
)
|
||||
}
|
||||
if (tab === 'runs') {
|
||||
await tick()
|
||||
runsSearch?.handleRunSearch(removePrefix(searchTerm, RUNS_PREFIX))
|
||||
await tick()
|
||||
runsSearch?.handleRunSearch(removePrefix(searchTerm, RUNS_PREFIX))
|
||||
}
|
||||
selectedItem = selectItem(0)
|
||||
}
|
||||
@@ -372,6 +371,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((itemMap[tab] ?? []).length === 0 && searchTerm.length > 0 && event.key === 'Enter') {
|
||||
askAiButton?.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +415,7 @@
|
||||
open = false
|
||||
goto(path)
|
||||
} else {
|
||||
window.open(path, "_blank")
|
||||
window.open(path, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,8 +583,7 @@
|
||||
let runsSearch: RunsSearch
|
||||
let runSearchRemainingCount: number | undefined = undefined
|
||||
let runSearchTotalCount: number | undefined = undefined
|
||||
let indexMetadata: SearchJobsIndexResponse["index_metadata"] = undefined
|
||||
|
||||
let indexMetadata: SearchJobsIndexResponse['index_metadata'] = undefined
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
@@ -618,6 +619,16 @@
|
||||
>{placeholderFromPrefix(searchTerm)}</label
|
||||
>
|
||||
</div>
|
||||
{#if (itemMap[tab] ?? []).length === 0 && searchTerm.length > 0}
|
||||
<AskAiButton
|
||||
bind:this={askAiButton}
|
||||
label="Ask AI"
|
||||
initialInput={searchTerm}
|
||||
onClick={() => {
|
||||
closeModal()
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if queryParseErrors.length > 0}
|
||||
<Popover notClickable placement="bottom-start">
|
||||
<AlertTriangle size={16} class="text-yellow-500" />
|
||||
@@ -682,7 +693,9 @@
|
||||
{#if (itemMap[tab] ?? []).length === 0}
|
||||
<div class="flex w-full justify-center items-center">
|
||||
<div class="text-tertiary text-center">
|
||||
<div class="text-2xl font-bold">Nothing found</div>
|
||||
<div class="text-2xl font-bold"
|
||||
>Nothing found, ask the AI to find what you need!</div
|
||||
>
|
||||
<div class="text-sm">Tip: press `esc` to quickly clear the search bar</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -718,20 +731,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === 'runs'}
|
||||
<RunsSearch
|
||||
bind:queryParseErrors
|
||||
bind:this={runsSearch}
|
||||
bind:selectedItem
|
||||
bind:selectedWorkspace
|
||||
bind:mouseMoved
|
||||
bind:loadedRuns={itemMap['runs']}
|
||||
bind:open
|
||||
{selectItem}
|
||||
searchTerm={removePrefix(searchTerm, RUNS_PREFIX)}
|
||||
bind:runSearchRemainingCount
|
||||
bind:runSearchTotalCount
|
||||
bind:indexMetadata
|
||||
/>
|
||||
<RunsSearch
|
||||
bind:queryParseErrors
|
||||
bind:this={runsSearch}
|
||||
bind:selectedItem
|
||||
bind:selectedWorkspace
|
||||
bind:mouseMoved
|
||||
bind:loadedRuns={itemMap['runs']}
|
||||
bind:open
|
||||
{selectItem}
|
||||
searchTerm={removePrefix(searchTerm, RUNS_PREFIX)}
|
||||
bind:runSearchRemainingCount
|
||||
bind:runSearchTotalCount
|
||||
bind:indexMetadata
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import ClipboardPanel from '../details/ClipboardPanel.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import MultiSelectWrapper from '../multiselect/MultiSelectWrapper.svelte'
|
||||
import TriggerableByAI from '../TriggerableByAI.svelte'
|
||||
|
||||
// --- Props ---
|
||||
interface Props {
|
||||
@@ -101,7 +102,7 @@
|
||||
label: newTokenLabel,
|
||||
expiration: date?.toISOString(),
|
||||
scopes: tokenScopes,
|
||||
workspace_id: mcpMode ? (newTokenWorkspace || $workspaceStore) : newTokenWorkspace
|
||||
workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace
|
||||
} as NewToken
|
||||
})
|
||||
|
||||
@@ -187,6 +188,8 @@
|
||||
<h2 class="py-0 my-0 border-b pt-3">Tokens</h2>
|
||||
<div class="flex justify-end border-b pb-1 gap-2">
|
||||
<Button
|
||||
aiId="account-settings-create-token"
|
||||
aiDescription="Create a new token to authenticate to the Windmill API"
|
||||
size="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
btnClasses={displayCreateToken ? 'hidden' : ''}
|
||||
@@ -235,28 +238,33 @@
|
||||
|
||||
{#if showMcpMode}
|
||||
<div class="mb-4 flex flex-row flex-shrink-0">
|
||||
<Toggle
|
||||
on:change={(e) => {
|
||||
mcpCreationMode = e.detail
|
||||
if (e.detail) {
|
||||
newTokenLabel = 'MCP token'
|
||||
newTokenExpiration = undefined
|
||||
newTokenWorkspace = $workspaceStore
|
||||
} else {
|
||||
newTokenLabel = undefined
|
||||
newTokenExpiration = undefined
|
||||
newTokenWorkspace = defaultNewTokenWorkspace
|
||||
}
|
||||
}}
|
||||
checked={mcpCreationMode}
|
||||
options={{
|
||||
right: 'Generate MCP URL',
|
||||
rightTooltip:
|
||||
'Generate a new MCP URL to make your scripts and flows available as tools through your LLM clients.',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/mcp'
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
<TriggerableByAI
|
||||
id="account-settings-create-mcp-token"
|
||||
description="Create a new MCP token to authenticate to the Windmill API"
|
||||
>
|
||||
<Toggle
|
||||
on:change={(e) => {
|
||||
mcpCreationMode = e.detail
|
||||
if (e.detail) {
|
||||
newTokenLabel = 'MCP token'
|
||||
newTokenExpiration = undefined
|
||||
newTokenWorkspace = $workspaceStore
|
||||
} else {
|
||||
newTokenLabel = undefined
|
||||
newTokenExpiration = undefined
|
||||
newTokenWorkspace = defaultNewTokenWorkspace
|
||||
}
|
||||
}}
|
||||
checked={mcpCreationMode}
|
||||
options={{
|
||||
right: 'Generate MCP URL',
|
||||
rightTooltip:
|
||||
'Generate a new MCP URL to make your scripts and flows available as tools through your LLM clients.',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/mcp'
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
</TriggerableByAI>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { conditionalMelt } from '$lib/utils'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let icon: any | undefined = undefined
|
||||
export let iconClasses: string | null = null
|
||||
export let isCollapsed: boolean
|
||||
export let disabled: boolean = false
|
||||
export let lightMode: boolean = false
|
||||
@@ -19,6 +23,8 @@
|
||||
export let trigger: MenubarMenuElements['trigger'] | undefined = undefined
|
||||
export let href: string | undefined = undefined
|
||||
|
||||
let buttonRef: HTMLButtonElement | undefined = undefined
|
||||
|
||||
let dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
@@ -30,68 +36,83 @@
|
||||
disablePopup={!isCollapsed}
|
||||
placement="right"
|
||||
>
|
||||
<button
|
||||
on:click={(e) => {
|
||||
if (stopPropagationOnClick) e.preventDefault()
|
||||
if (href) {
|
||||
goto(href)
|
||||
<TriggerableByAI
|
||||
id={aiId}
|
||||
description={aiDescription}
|
||||
onTrigger={() => {
|
||||
if (buttonRef) {
|
||||
buttonRef.click()
|
||||
}
|
||||
dispatch('click')
|
||||
}}
|
||||
class={twMerge(
|
||||
'group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full',
|
||||
lightMode
|
||||
? 'text-primary data-[highlighted]:bg-surface-hover hover:bg-surface-hover'
|
||||
: 'data-[highlighted]:bg-[#2A3648] hover:bg-[#2A3648] text-primary-inverse dark:text-primary',
|
||||
color ? 'border-4' : '',
|
||||
'transition-all relative',
|
||||
$$props.class
|
||||
)}
|
||||
style={color ? `border-color: ${color}; padding: 0 calc(0.5rem - 4px);` : ''}
|
||||
use:conditionalMelt={trigger}
|
||||
title={isCollapsed ? undefined : label}
|
||||
{...$trigger}
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component
|
||||
this={icon}
|
||||
size={16}
|
||||
class={twMerge(
|
||||
'flex-shrink-0',
|
||||
lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-primary-inverse group-hover:text-secondary-inverse dark:group-hover:text-secondary dark:text-primary',
|
||||
'transition-all'
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
on:click={(e) => {
|
||||
if (stopPropagationOnClick) e.preventDefault()
|
||||
if (href) {
|
||||
goto(href)
|
||||
}
|
||||
dispatch('click')
|
||||
}}
|
||||
class={twMerge(
|
||||
'group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full',
|
||||
lightMode
|
||||
? 'text-primary data-[highlighted]:bg-surface-hover hover:bg-surface-hover'
|
||||
: 'data-[highlighted]:bg-[#2A3648] hover:bg-[#2A3648] text-primary-inverse dark:text-primary',
|
||||
color ? 'border-4' : '',
|
||||
'transition-all relative',
|
||||
$$props.class
|
||||
)}
|
||||
style={color ? `border-color: ${color}; padding: 0 calc(0.5rem - 4px);` : ''}
|
||||
use:conditionalMelt={trigger}
|
||||
title={isCollapsed ? undefined : label}
|
||||
{...$trigger}
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component
|
||||
this={icon}
|
||||
size={16}
|
||||
class={twMerge(
|
||||
'flex-shrink-0',
|
||||
lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-primary-inverse group-hover:text-secondary-inverse dark:group-hover:text-secondary dark:text-primary',
|
||||
'transition-all',
|
||||
iconClasses
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !isCollapsed && label}
|
||||
<span
|
||||
class={twMerge(
|
||||
'whitespace-pre truncate',
|
||||
lightMode ? 'text-primary' : 'text-primary-inverse dark:text-primary',
|
||||
'transition-all',
|
||||
$$props.class
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span class="pl-2 text-xs dark:text-secondary light:text-secondary-inverse font-semibold">
|
||||
{shortcut}
|
||||
{#if !isCollapsed && label}
|
||||
<span
|
||||
class={twMerge(
|
||||
'whitespace-pre truncate',
|
||||
lightMode ? 'text-primary' : 'text-primary-inverse dark:text-primary',
|
||||
'transition-all',
|
||||
$$props.class
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
class="pl-2 text-xs dark:text-secondary light:text-secondary-inverse font-semibold"
|
||||
>
|
||||
{shortcut}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isCollapsed && notificationsCount > 0}
|
||||
<div class="absolute translate-x-1/2 translate-y-1/2 -top-2 right-1 flex h-fit w-fit">
|
||||
<SideBarNotification notificationCount={notificationsCount} small={true} />
|
||||
</div>
|
||||
{:else if notificationsCount > 0}
|
||||
<div class="ml-auto">
|
||||
<SideBarNotification notificationCount={notificationsCount} small={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</TriggerableByAI>
|
||||
|
||||
{#if isCollapsed && notificationsCount > 0}
|
||||
<div class="absolute translate-x-1/2 translate-y-1/2 -top-2 right-1 flex h-fit w-fit">
|
||||
<SideBarNotification notificationCount={notificationsCount} small={true} />
|
||||
</div>
|
||||
{:else if notificationsCount > 0}
|
||||
<div class="ml-auto">
|
||||
<SideBarNotification notificationCount={notificationsCount} small={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
<svelte:fragment slot="text">
|
||||
{#if label}
|
||||
{label}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { navigating, page } from '$app/stores'
|
||||
import Popover from '../Popover.svelte'
|
||||
import { base } from '$app/paths'
|
||||
import TriggerableByAI from '$lib/components/TriggerableByAI.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
export let aiId: string | undefined = undefined
|
||||
export let aiDescription: string | undefined = undefined
|
||||
export let label: string
|
||||
export let href: string
|
||||
export let icon: any | undefined = undefined
|
||||
@@ -25,65 +29,73 @@
|
||||
</script>
|
||||
|
||||
{#if !disabled}
|
||||
<Popover appearTimeout={0} disappearTimeout={0} class="w-full" disablePopup={!isCollapsed}>
|
||||
<a
|
||||
{href}
|
||||
class={classNames(
|
||||
'group flex items-center px-2 py-2 text-sm font-light rounded-md h-8 gap-3',
|
||||
isSelected
|
||||
? lightMode
|
||||
? 'bg-surface-selected hover:bg-surface-hover rounded-none data-[highlighted]:bg-surface-hover'
|
||||
: 'bg-frost-700 hover:bg-[#30404e] data-[highlighted]:bg-[#30404e]'
|
||||
: lightMode
|
||||
? 'hover:bg-surface-hover rounded-none data-[highlighted]:bg-surface-hover'
|
||||
: 'hover:bg-[#2A3648] data-[highlighted]:bg-[#2A3648]',
|
||||
<TriggerableByAI
|
||||
id={aiId}
|
||||
description={aiDescription}
|
||||
onTrigger={() => {
|
||||
goto(href)
|
||||
}}
|
||||
>
|
||||
<Popover appearTimeout={0} disappearTimeout={0} class="w-full" disablePopup={!isCollapsed}>
|
||||
<a
|
||||
{href}
|
||||
class={classNames(
|
||||
'group flex items-center px-2 py-2 text-sm font-light rounded-md h-8 gap-3',
|
||||
isSelected
|
||||
? lightMode
|
||||
? 'bg-surface-selected hover:bg-surface-hover rounded-none data-[highlighted]:bg-surface-hover'
|
||||
: 'bg-frost-700 hover:bg-[#30404e] data-[highlighted]:bg-[#30404e]'
|
||||
: lightMode
|
||||
? 'hover:bg-surface-hover rounded-none data-[highlighted]:bg-surface-hover'
|
||||
: 'hover:bg-[#2A3648] data-[highlighted]:bg-[#2A3648]',
|
||||
|
||||
'hover:transition-all',
|
||||
$$props.class
|
||||
)}
|
||||
target={href.includes('http') ? '_blank' : null}
|
||||
title={isCollapsed ? undefined : label}
|
||||
use:conditionalMelt={item}
|
||||
{...$item}
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component
|
||||
this={icon}
|
||||
size={16}
|
||||
class={classNames(
|
||||
'flex-shrink-0',
|
||||
isSelected
|
||||
? lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-frost-200 group-hover:text-white'
|
||||
: lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-gray-100 group-hover:text-white',
|
||||
'transition-all'
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
'hover:transition-all',
|
||||
$$props.class
|
||||
)}
|
||||
target={href.includes('http') ? '_blank' : null}
|
||||
title={isCollapsed ? undefined : label}
|
||||
use:conditionalMelt={item}
|
||||
{...$item}
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component
|
||||
this={icon}
|
||||
size={16}
|
||||
class={classNames(
|
||||
'flex-shrink-0',
|
||||
isSelected
|
||||
? lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-frost-200 group-hover:text-white'
|
||||
: lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-gray-100 group-hover:text-white',
|
||||
'transition-all'
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !isCollapsed}
|
||||
<span
|
||||
class={classNames(
|
||||
'whitespace-pre truncate',
|
||||
isSelected
|
||||
? lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-frost-200 group-hover:text-white'
|
||||
: lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-gray-100 group-hover:text-white',
|
||||
'transition-all duration-75'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
<svelte:fragment slot="text">
|
||||
{label}
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{#if !isCollapsed}
|
||||
<span
|
||||
class={classNames(
|
||||
'whitespace-pre truncate',
|
||||
isSelected
|
||||
? lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-frost-200 group-hover:text-white'
|
||||
: lightMode
|
||||
? 'text-primary group-hover:text-secondary'
|
||||
: 'text-gray-100 group-hover:text-white',
|
||||
'transition-all duration-75'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
<svelte:fragment slot="text">
|
||||
{label}
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
</TriggerableByAI>
|
||||
{/if}
|
||||
|
||||
@@ -65,15 +65,37 @@
|
||||
export let numUnacknowledgedCriticalAlerts = 0
|
||||
|
||||
$: mainMenuLinks = [
|
||||
{ label: 'Home', href: `${base}/`, icon: Home },
|
||||
{ label: 'Runs', href: `${base}/runs`, icon: Play },
|
||||
{
|
||||
label: 'Home',
|
||||
href: `${base}/`,
|
||||
icon: Home,
|
||||
aiId: 'sidebar-menu-link-home',
|
||||
aiDescription:
|
||||
"Button to navigate to home which contains all the user's scripts, flows and apps"
|
||||
},
|
||||
{
|
||||
label: 'Runs',
|
||||
href: `${base}/runs`,
|
||||
icon: Play,
|
||||
aiId: 'sidebar-menu-link-runs',
|
||||
aiDescription: 'Button to navigate to runs'
|
||||
},
|
||||
{
|
||||
label: 'Variables',
|
||||
href: `${base}/variables`,
|
||||
icon: DollarSign,
|
||||
disabled: $userStore?.operator
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-variables',
|
||||
aiDescription: 'Button to navigate to variables'
|
||||
},
|
||||
{ label: 'Resources', href: `${base}/resources`, icon: Boxes, disabled: $userStore?.operator }
|
||||
{
|
||||
label: 'Resources',
|
||||
href: `${base}/resources`,
|
||||
icon: Boxes,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-resources',
|
||||
aiDescription: 'Button to navigate to resources'
|
||||
}
|
||||
]
|
||||
|
||||
$: triggerMenuLinks = [
|
||||
@@ -81,7 +103,9 @@
|
||||
label: 'Schedules',
|
||||
href: `${base}/schedules`,
|
||||
icon: Calendar,
|
||||
disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator
|
||||
disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-schedules',
|
||||
aiDescription: 'Button to navigate to schedules'
|
||||
},
|
||||
...defaultExtraTriggerLinks.filter(
|
||||
(link) => $usedTriggerKinds.includes(link.kind) || $page.url.pathname.includes(link.href)
|
||||
@@ -100,56 +124,72 @@
|
||||
href: '/routes',
|
||||
icon: Route,
|
||||
disabled: $userStore?.operator,
|
||||
kind: 'http'
|
||||
kind: 'http',
|
||||
aiId: 'sidebar-menu-link-http',
|
||||
aiDescription: 'Button to navigate to HTTP routes'
|
||||
},
|
||||
{
|
||||
label: 'WebSockets',
|
||||
href: '/websocket_triggers',
|
||||
icon: Unplug,
|
||||
disabled: $userStore?.operator,
|
||||
kind: 'ws'
|
||||
kind: 'ws',
|
||||
aiId: 'sidebar-menu-link-ws',
|
||||
aiDescription: 'Button to navigate to websocket triggers'
|
||||
},
|
||||
{
|
||||
label: 'Postgres',
|
||||
href: '/postgres_triggers',
|
||||
icon: Database,
|
||||
disabled: $userStore?.operator,
|
||||
kind: 'postgres'
|
||||
kind: 'postgres',
|
||||
aiId: 'sidebar-menu-link-postgres',
|
||||
aiDescription: 'Button to navigate to Postgres triggers'
|
||||
},
|
||||
{
|
||||
label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/kafka_triggers',
|
||||
icon: KafkaIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
kind: 'kafka'
|
||||
kind: 'kafka',
|
||||
aiId: 'sidebar-menu-link-kafka',
|
||||
aiDescription: 'Button to navigate to Kafka triggers'
|
||||
},
|
||||
{
|
||||
label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/nats_triggers',
|
||||
icon: NatsIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
kind: 'nats'
|
||||
kind: 'nats',
|
||||
aiId: 'sidebar-menu-link-nats',
|
||||
aiDescription: 'Button to navigate to NATS triggers'
|
||||
},
|
||||
{
|
||||
label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/sqs_triggers',
|
||||
icon: AwsIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
kind: 'sqs'
|
||||
kind: 'sqs',
|
||||
aiId: 'sidebar-menu-link-sqs',
|
||||
aiDescription: 'Button to navigate to SQS triggers'
|
||||
},
|
||||
{
|
||||
label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/gcp_triggers',
|
||||
icon: GoogleCloudIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
kind: 'gcp'
|
||||
kind: 'gcp',
|
||||
aiId: 'sidebar-menu-link-gcp',
|
||||
aiDescription: 'Button to navigate to GCP Pub/Sub triggers'
|
||||
},
|
||||
{
|
||||
label: 'MQTT',
|
||||
href: '/mqtt_triggers',
|
||||
icon: MqttIcon,
|
||||
disabled: $userStore?.operator,
|
||||
kind: 'mqtt'
|
||||
kind: 'mqtt',
|
||||
aiId: 'sidebar-menu-link-mqtt',
|
||||
aiDescription: 'Button to navigate to MQTT triggers'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -166,11 +206,16 @@
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
aiId: 'sidebar-menu-link-settings',
|
||||
aiDescription:
|
||||
'Button to navigate to settings, including account, workspace, and instance settings',
|
||||
subItems: [
|
||||
{
|
||||
label: 'Account',
|
||||
href: '#user-settings',
|
||||
icon: UserCog,
|
||||
aiId: 'sidebar-menu-link-account',
|
||||
aiDescription: 'Button to navigate to account settings',
|
||||
faIcon: undefined
|
||||
},
|
||||
...($userStore?.is_admin || $superadmin
|
||||
@@ -179,6 +224,8 @@
|
||||
label: 'Workspace',
|
||||
href: `${base}/workspace_settings`,
|
||||
icon: FolderCog,
|
||||
aiId: 'sidebar-menu-link-workspace',
|
||||
aiDescription: 'Button to navigate to workspace settings',
|
||||
faIcon: undefined
|
||||
}
|
||||
]
|
||||
@@ -189,6 +236,8 @@
|
||||
label: 'Instance',
|
||||
href: '#superadmin-settings',
|
||||
icon: ServerCog,
|
||||
aiId: 'sidebar-menu-link-instance',
|
||||
aiDescription: 'Button to navigate to instance settings',
|
||||
faIcon: undefined
|
||||
}
|
||||
]
|
||||
@@ -209,16 +258,27 @@
|
||||
],
|
||||
disabled: $userStore?.operator
|
||||
},
|
||||
{ label: 'Workers', href: `${base}/workers`, icon: Bot, disabled: $userStore?.operator },
|
||||
{
|
||||
label: 'Workers',
|
||||
href: `${base}/workers`,
|
||||
icon: Bot,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-workers',
|
||||
aiDescription: 'Button to navigate to workers'
|
||||
},
|
||||
{
|
||||
label: 'Folders & Groups',
|
||||
icon: FolderOpen,
|
||||
aiId: 'sidebar-menu-link-folders-groups',
|
||||
aiDescription: 'Button to navigate to folders and groups',
|
||||
subItems: [
|
||||
{
|
||||
label: 'Folders',
|
||||
href: `${base}/folders`,
|
||||
icon: FolderOpen,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-folders',
|
||||
aiDescription: 'Button to navigate to folders',
|
||||
faIcon: undefined
|
||||
},
|
||||
{
|
||||
@@ -226,6 +286,8 @@
|
||||
href: `${base}/groups`,
|
||||
icon: UserCog,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-groups',
|
||||
aiDescription: 'Button to navigate to groups',
|
||||
faIcon: undefined
|
||||
}
|
||||
],
|
||||
@@ -235,18 +297,24 @@
|
||||
? {
|
||||
label: 'Logs',
|
||||
icon: Logs,
|
||||
aiId: 'sidebar-menu-link-logs',
|
||||
aiDescription: 'Button to navigate to logs',
|
||||
subItems: [
|
||||
{
|
||||
label: 'Audit logs',
|
||||
href: `${base}/audit_logs`,
|
||||
icon: Eye
|
||||
icon: Eye,
|
||||
aiId: 'sidebar-menu-link-audit-logs',
|
||||
aiDescription: 'Button to navigate to audit logs'
|
||||
},
|
||||
...($devopsRole
|
||||
? [
|
||||
{
|
||||
label: 'Service logs',
|
||||
href: `${base}/service_logs`,
|
||||
icon: Logs
|
||||
icon: Logs,
|
||||
aiId: 'sidebar-menu-link-service-logs',
|
||||
aiDescription: 'Button to navigate to service logs'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
@@ -258,7 +326,9 @@
|
||||
isCriticalAlertsUIOpen.set(true)
|
||||
},
|
||||
icon: AlertCircle,
|
||||
notificationCount: numUnacknowledgedCriticalAlerts
|
||||
notificationCount: numUnacknowledgedCriticalAlerts,
|
||||
aiId: 'sidebar-menu-link-critical-alerts',
|
||||
aiDescription: 'Button to navigate to critical alerts'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -268,7 +338,9 @@
|
||||
label: 'Audit logs',
|
||||
href: `${base}/audit_logs`,
|
||||
icon: Eye,
|
||||
disabled: $userStore?.operator
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-audit-logs',
|
||||
aiDescription: 'Button to navigate to audit logs'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -298,21 +370,33 @@
|
||||
label: 'Help',
|
||||
icon: HelpCircle,
|
||||
subItems: [
|
||||
{ label: 'Docs', href: 'https://www.windmill.dev/docs/intro/', icon: BookOpen },
|
||||
{
|
||||
label: 'Docs',
|
||||
href: 'https://www.windmill.dev/docs/intro/',
|
||||
icon: BookOpen,
|
||||
aiId: 'sidebar-menu-link-docs',
|
||||
aiDescription: 'Button to navigate to docs'
|
||||
},
|
||||
{
|
||||
label: 'Feedbacks',
|
||||
href: 'https://discord.gg/V7PM2YHsPB',
|
||||
icon: DiscordIcon
|
||||
icon: DiscordIcon,
|
||||
aiId: 'sidebar-menu-link-feedbacks',
|
||||
aiDescription: 'Button to navigate to feedbacks'
|
||||
},
|
||||
{
|
||||
label: 'Issues',
|
||||
href: 'https://github.com/windmill-labs/windmill/issues/new',
|
||||
icon: Github
|
||||
icon: Github,
|
||||
aiId: 'sidebar-menu-link-issues',
|
||||
aiDescription: 'Button to navigate to issues'
|
||||
},
|
||||
{
|
||||
label: 'Changelog',
|
||||
href: 'https://www.windmill.dev/changelog/',
|
||||
icon: Newspaper
|
||||
icon: Newspaper,
|
||||
aiId: 'sidebar-menu-link-changelog',
|
||||
aiDescription: 'Button to navigate to changelog'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -370,6 +454,8 @@
|
||||
</svelte:fragment>
|
||||
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem
|
||||
aiId={subItem.aiId}
|
||||
aiDescription={subItem.aiDescription}
|
||||
href={subItem.disabled ? '' : subItem.href}
|
||||
class={twMerge(itemClass, subItem.disabled ? 'pointer-events-none opacity-50' : '')}
|
||||
{item}
|
||||
@@ -414,6 +500,8 @@
|
||||
on:click={() => {
|
||||
subItem?.['action']?.()
|
||||
}}
|
||||
aiId={subItem.aiId}
|
||||
aiDescription={subItem.aiDescription}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if subItem.icon}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import AddTriggersButton from './AddTriggersButton.svelte'
|
||||
import TriggerLabel from './TriggerLabel.svelte'
|
||||
import DeleteTriggerButton from './DeleteTriggerButton.svelte'
|
||||
import TriggerableByAI from '../TriggerableByAI.svelte'
|
||||
|
||||
interface Props {
|
||||
// Props
|
||||
@@ -41,6 +42,8 @@
|
||||
<div class="w-full">
|
||||
<AddTriggersButton {onAddDraftTrigger} setDropdownWidthToButtonWidth class="w-full" {isEditor}>
|
||||
<Button
|
||||
aiId="add-trigger"
|
||||
aiDescription="Add a new trigger"
|
||||
size="xs"
|
||||
color="blue"
|
||||
startIcon={{ icon: Plus }}
|
||||
@@ -62,54 +65,60 @@
|
||||
)}
|
||||
onclick={() => onSelect?.(index)}
|
||||
>
|
||||
<td class="w-12 text-center py-2 px-2">
|
||||
<div class="relative flex justify-center items-center">
|
||||
<SvelteComponent
|
||||
size={16}
|
||||
class={trigger.isDraft ? 'text-frost-400' : 'text-tertiary'}
|
||||
/>
|
||||
<TriggerableByAI
|
||||
id={`trigger-${trigger.id}`}
|
||||
description={`See ${trigger.type} triggers`}
|
||||
onTrigger={() => onSelect?.(index)}
|
||||
>
|
||||
<td class="w-12 text-center py-2 px-2">
|
||||
<div class="relative flex justify-center items-center">
|
||||
<SvelteComponent
|
||||
size={16}
|
||||
class={trigger.isDraft ? 'text-frost-400' : 'text-tertiary'}
|
||||
/>
|
||||
|
||||
{#if trigger.isPrimary}
|
||||
<Star size={10} class="absolute -mt-3 ml-3 text-blue-400" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="flex items-center grow min-w-0">
|
||||
<TriggerLabel {trigger} />
|
||||
{#if trigger.type === 'webhook' && webhookToken}
|
||||
<span
|
||||
class="ml-2 px-1.5 text-xs rounded-md bg-tertiary/50 group-hover:bg-primary text-primary-inverse py-0.5"
|
||||
>
|
||||
{`${webhookToken} token${webhookToken > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
{:else if trigger.type === 'email' && emailToken}
|
||||
<span
|
||||
class="ml-2 text-xs rounded-md bg-tertiary/50 group-hover:bg-primary text-primary-inverse px-1.5 py-0.5"
|
||||
>
|
||||
{`${emailToken} token${emailToken > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
{#if trigger.isPrimary}
|
||||
<Star size={10} class="absolute -mt-3 ml-3 text-blue-400" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="flex items-center grow min-w-0">
|
||||
<TriggerLabel {trigger} />
|
||||
{#if trigger.type === 'webhook' && webhookToken}
|
||||
<span
|
||||
class="ml-2 px-1.5 text-xs rounded-md bg-tertiary/50 group-hover:bg-primary text-primary-inverse py-0.5"
|
||||
>
|
||||
{`${webhookToken} token${webhookToken > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
{:else if trigger.type === 'email' && emailToken}
|
||||
<span
|
||||
class="ml-2 text-xs rounded-md bg-tertiary/50 group-hover:bg-primary text-primary-inverse px-1.5 py-0.5"
|
||||
>
|
||||
{`${emailToken} token${emailToken > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !['email', 'webhook', 'cli'].includes(trigger.type)}
|
||||
{#if trigger.isDraft}
|
||||
<DeleteTriggerButton {trigger} onDelete={() => onDeleteDraft?.(index)} small />
|
||||
{:else if !!trigger.draftConfig && !trigger.isDraft}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
btnClasses="transition-all duration-200 text-transparent hover:bg-surface group-hover:text-primary bg-transparent px-1 py-1"
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
iconOnly
|
||||
title="Reset to deployed version"
|
||||
on:click={() => onReset?.(index)}
|
||||
/>
|
||||
{#if !['email', 'webhook', 'cli'].includes(trigger.type)}
|
||||
{#if trigger.isDraft}
|
||||
<DeleteTriggerButton {trigger} onDelete={() => onDeleteDraft?.(index)} small />
|
||||
{:else if !!trigger.draftConfig && !trigger.isDraft}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
btnClasses="transition-all duration-200 text-transparent hover:bg-surface group-hover:text-primary bg-transparent px-1 py-1"
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
iconOnly
|
||||
title="Reset to deployed version"
|
||||
on:click={() => onReset?.(index)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</div>
|
||||
</td>
|
||||
</TriggerableByAI>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
|
||||
@@ -105,7 +105,6 @@ export const copilotInfo = writable<{
|
||||
defaultModel: undefined,
|
||||
aiModels: []
|
||||
})
|
||||
export const chatMode = writable<'script' | 'flow'>('script')
|
||||
|
||||
export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
if (Object.keys(aiConfig.providers ?? {}).length > 0) {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { syncTutorialsTodos } from '$lib/tutorialUtils'
|
||||
import { ArrowLeft, Search } from 'lucide-svelte'
|
||||
import { ArrowLeft, Search, WandSparkles } from 'lucide-svelte'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { workspaceAIClients } from '$lib/components/copilot/lib'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -53,7 +53,9 @@
|
||||
import { setContext } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
|
||||
import GlobalChat from '$lib/components/copilot/chat/navigator/GlobalChat.svelte'
|
||||
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
OpenAPI.WITH_CREDENTIALS = true
|
||||
let menuOpen = false
|
||||
let globalSearchModal: GlobalSearchModal | undefined = undefined
|
||||
@@ -367,7 +369,7 @@
|
||||
{#if mountModal}
|
||||
<CriticalAlertModal bind:muteSettings bind:numUnacknowledgedCriticalAlerts />
|
||||
{/if}
|
||||
<div>
|
||||
<div class="h-screen flex flex-col">
|
||||
{#if !menuHidden}
|
||||
{#if !$userStore?.operator}
|
||||
{#if innerWidth < 768}
|
||||
@@ -449,6 +451,15 @@
|
||||
class="!text-xs"
|
||||
shortcut={`${getModifierKey()}k`}
|
||||
/>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
isCollapsed={false}
|
||||
icon={WandSparkles}
|
||||
label="Ask AI"
|
||||
class="!text-xs"
|
||||
iconClasses="!text-violet-400 dark:!text-violet-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarContent
|
||||
@@ -508,6 +519,15 @@
|
||||
class="!text-xs"
|
||||
shortcut={`${getModifierKey()}k`}
|
||||
/>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
{isCollapsed}
|
||||
icon={WandSparkles}
|
||||
label="Ask AI"
|
||||
class="!text-xs"
|
||||
iconClasses="!text-violet-400 dark:!text-violet-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarContent
|
||||
@@ -545,7 +565,7 @@
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 dark:bg-[#1e232e] bg-[#202125] dark:bg-opacity-75 bg-opacity-75 transition-opacity ease-linear duration-300 !dark',
|
||||
'opacity-0'
|
||||
'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div class={twMerge('fixed inset-0 flex ', '-z-0')}>
|
||||
@@ -607,6 +627,15 @@
|
||||
class="!text-xs"
|
||||
shortcut={`${getModifierKey()}k`}
|
||||
/>
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={() => aiChatManager.toggleOpen()}
|
||||
{isCollapsed}
|
||||
icon={WandSparkles}
|
||||
label="Ask AI"
|
||||
class="!text-xs"
|
||||
iconClasses="!text-violet-400 dark:!text-violet-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarContent
|
||||
@@ -620,47 +649,60 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id="content"
|
||||
class={classNames(
|
||||
'w-full flex flex-col flex-1 h-full',
|
||||
devOnly || $userStore?.operator ? '!pl-0' : isCollapsed ? 'md:pl-12' : 'md:pl-40',
|
||||
'transition-all ease-in-out duration-200'
|
||||
)}
|
||||
>
|
||||
<main class="min-h-screen">
|
||||
<div class="relative w-full h-full">
|
||||
<div
|
||||
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',
|
||||
devOnly || $userStore?.operator ? 'hidden' : ''
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-label="Menu"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
class="h-8 w-8 inline-flex items-center justify-center rounded-md text-tertiary hover:text-primary focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
<Splitpanes horizontal={false} class="flex-1 min-h-0">
|
||||
<Pane size={100 - aiChatManager.size} minSize={50} class="flex flex-col min-h-0">
|
||||
<div
|
||||
id="content"
|
||||
class={classNames(
|
||||
'w-full flex-1 flex flex-col overflow-y-auto',
|
||||
devOnly || $userStore?.operator ? '!pl-0' : isCollapsed ? 'md:pl-12' : 'md:pl-40',
|
||||
'transition-all ease-in-out duration-200'
|
||||
)}
|
||||
>
|
||||
<main class="flex-1 flex flex-col">
|
||||
<div class="relative w-full flex-1 flex flex-col">
|
||||
<div
|
||||
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',
|
||||
devOnly || $userStore?.operator ? 'hidden' : ''
|
||||
)}
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<slot />
|
||||
<button
|
||||
aria-label="Menu"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
class="h-8 w-8 inline-flex items-center justify-center rounded-md text-tertiary hover:text-primary focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane bind:size={aiChatManager.size} minSize={15} class="flex flex-col min-h-0">
|
||||
<GlobalChat />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredModal title="Loading user...">
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
</Drawer>
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 h-fit-content">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 md:px-8 h-fit-content">
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<div class="my-4"></div>
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
<div class="flex flex-row gap-4 flex-wrap justify-end items-center">
|
||||
{#if !$userStore?.operator}
|
||||
<span class="text-sm text-secondary">Create a</span>
|
||||
<CreateActionsScript />
|
||||
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
|
||||
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
|
||||
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
|
||||
{/if}
|
||||
|
||||
@@ -616,10 +616,23 @@
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
<div class="flex flex-row justify-end gap-4">
|
||||
<Button variant="border" size="md" startIcon={{ icon: Plus }} on:click={startNewType}>
|
||||
<Button
|
||||
variant="border"
|
||||
size="md"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={startNewType}
|
||||
aiId="resources-add-resource-type"
|
||||
aiDescription="Add resource type"
|
||||
>
|
||||
Add resource type
|
||||
</Button>
|
||||
<Button size="md" startIcon={{ icon: Link }} on:click={() => appConnect.open?.()}>
|
||||
<Button
|
||||
size="md"
|
||||
startIcon={{ icon: Link }}
|
||||
on:click={() => appConnect.open?.()}
|
||||
aiId="resources-add-resource"
|
||||
aiDescription="Add resource"
|
||||
>
|
||||
Add resource
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -256,8 +256,14 @@
|
||||
tooltip="Trigger Scripts and Flows according to a cron schedule"
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/scheduling"
|
||||
>
|
||||
<Button size="md" startIcon={{ icon: Plus }} on:click={() => scheduleEditor.openNew(false)}>
|
||||
New schedule
|
||||
<Button
|
||||
size="md"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => scheduleEditor.openNew(false)}
|
||||
aiId="schedules-add-schedule"
|
||||
aiDescription="Add schedule"
|
||||
>
|
||||
New schedule
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
|
||||
@@ -644,46 +644,106 @@
|
||||
goto(`?${$page.url.searchParams.toString()}`)
|
||||
}}
|
||||
>
|
||||
<Tab size="xs" value="users">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="users"
|
||||
aiId="workspace-settings-users"
|
||||
aiDescription="Users workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1"> Users</div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="git_sync">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="git_sync"
|
||||
aiId="workspace-settings-git-sync"
|
||||
aiDescription="Git sync workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1">Git Sync</div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="deploy_to">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="deploy_to"
|
||||
aiId="workspace-settings-deploy-to"
|
||||
aiDescription="Deployment UI workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1">Deployment UI</div>
|
||||
</Tab>
|
||||
{#if WORKSPACE_SHOW_SLACK_CMD}
|
||||
<Tab size="xs" value="slack">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="slack"
|
||||
aiId="workspace-settings-slack"
|
||||
aiDescription="Slack / Teams workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1"> Slack / Teams</div>
|
||||
</Tab>
|
||||
{/if}
|
||||
{#if isCloudHosted()}
|
||||
<Tab size="xs" value="premium">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="premium"
|
||||
aiId="workspace-settings-premium"
|
||||
aiDescription="Premium plans workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1"> Premium Plans </div>
|
||||
</Tab>
|
||||
{/if}
|
||||
{#if WORKSPACE_SHOW_WEBHOOK_CLI_SYNC}
|
||||
<Tab size="xs" value="webhook">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="webhook"
|
||||
aiId="workspace-settings-webhook"
|
||||
aiDescription="Webhook workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1">Webhook</div>
|
||||
</Tab>
|
||||
{/if}
|
||||
<Tab size="xs" value="error_handler">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="error_handler"
|
||||
aiId="workspace-settings-error-handler"
|
||||
aiDescription="Error handler workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1">Error Handler</div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="ai">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="ai"
|
||||
aiId="workspace-settings-ai"
|
||||
aiDescription="Windmill AI workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1">Windmill AI</div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="windmill_lfs">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="windmill_lfs"
|
||||
aiId="workspace-settings-windmill-lfs"
|
||||
aiDescription="Object Storage (S3) workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1"> Object Storage (S3)</div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="default_app">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="default_app"
|
||||
aiId="workspace-settings-default-app"
|
||||
aiDescription="Default app workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1"> Default App </div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="encryption">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="encryption"
|
||||
aiId="workspace-settings-encryption"
|
||||
aiDescription="Encryption workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1"> Encryption </div>
|
||||
</Tab>
|
||||
<Tab size="xs" value="general">
|
||||
<Tab
|
||||
size="xs"
|
||||
value="general"
|
||||
aiId="workspace-settings-general"
|
||||
aiDescription="General workspace settings"
|
||||
>
|
||||
<div class="flex gap-2 items-center my-1"> General </div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Reference in New Issue
Block a user