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:
centdix
2025-06-04 19:17:25 +02:00
committed by GitHub
parent 55eaf3f2bc
commit d24825c3ea
58 changed files with 2811 additions and 1785 deletions
+11
View File
@@ -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()
}
+4
View File
@@ -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>
+43 -31
View File
@@ -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"
+57 -111
View File
@@ -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>
+17 -16
View File
@@ -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',
+208 -205
View File
@@ -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}
-1
View File
@@ -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&nbsp;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>