Merge branch 'glm/replace_menuV2' into glm/use_melt_menu

# Conflicts:
#	frontend/src/lib/components/apps/components/display/AppMenu.svelte
#	frontend/src/lib/components/flows/map/FlowJobsMenu.svelte
#	frontend/src/lib/components/meltComponents/Menu.svelte
#	frontend/src/lib/components/meltComponents/MenuItem.svelte
#	frontend/src/lib/components/meltComponents/index.ts
This commit is contained in:
Guilhem
2025-02-11 13:47:47 +00:00
521 changed files with 12320 additions and 5721 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.456.0",
"version": "1.458.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.456.0",
"version": "1.458.4",
"license": "AGPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.456.0",
"version": "1.458.4",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -25,6 +25,7 @@
import { Pen } from 'lucide-svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect'
import type { SchemaProperty } from '$lib/common'
export let step = 1
export let resourceType = ''
@@ -55,18 +56,20 @@
let args: any = {}
let renderDescription = true
function computeCandidates(resourceType: string, argsKeys: string[]) {
function computeCandidates(resourceType: string, argsKeys: string[], passwords: string[]) {
return apiTokenApps[resourceType]?.linkedSecret
? ([apiTokenApps[resourceType]?.linkedSecret] as string[])
: argsKeys.filter((x) =>
['token', 'secret', 'key', 'pass', 'private'].some((y) => x.toLowerCase().includes(y))
: argsKeys.filter(
(x) =>
passwords.includes(x) ||
['token', 'secret', 'key', 'pass', 'private'].some((y) => x.toLowerCase().includes(y))
)
}
let linkedSecret: string | undefined = undefined
let linkedSecretCandidates: string[] | undefined = undefined
function computeLinkedSecret(resourceType: string, argsKeys: string[]) {
linkedSecretCandidates = computeCandidates(resourceType, argsKeys)
function computeLinkedSecret(resourceType: string, argsKeys: string[], passwords: string[]) {
linkedSecretCandidates = computeCandidates(resourceType, argsKeys, passwords)
return (
forceSecretValue(resourceType) ??
linkedSecretCandidates?.sort((ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua))?.[0]
@@ -240,9 +243,13 @@
workspace: $workspaceStore!,
path: resourceType
})
const newArgsKeys = Object.keys(resourceTypeInfo?.schema?.['properties'] ?? {}) ?? []
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
const newArgsKeys = Object.keys(props) ?? []
const passwords = newArgsKeys.filter((x) => {
return props?.[x]?.password
})
if (!linkedSecret) {
linkedSecret = computeLinkedSecret(resourceType, newArgsKeys)
linkedSecret = computeLinkedSecret(resourceType, newArgsKeys, passwords)
}
}
export async function next() {
+7 -1
View File
@@ -230,7 +230,11 @@
let oldDefaultValue = structuredClone(defaultValue)
function handleDefaultValueChange() {
if (deepEqual(value, oldDefaultValue)) {
if (
deepEqual(value, oldDefaultValue) &&
!deepEqual(value, defaultValue) &&
!deepEqual(defaultValue, oldDefaultValue)
) {
value = defaultValue
}
oldDefaultValue = structuredClone(defaultValue)
@@ -489,6 +493,7 @@
<div class="items-start">
<Multiselect
ulOptionsClass={'p-2 !bg-surface-secondary'}
outerDivClass={'dark:!border-gray-500 !border-gray-300'}
{disabled}
bind:selected={value}
options={itemsType?.multiselect ?? []}
@@ -502,6 +507,7 @@
<div class="items-start">
<Multiselect
ulOptionsClass={'p-2 !bg-surface-secondary'}
outerDivClass={'dark:!border-gray-500 !border-gray-300'}
{disabled}
bind:selected={value}
options={itemsType?.enum ?? []}
@@ -10,6 +10,7 @@
import type { SchemaProperty } from '$lib/common'
export let canEditResourceType: boolean = false
export let originalType: string | undefined = undefined
export let itemsType:
| {
type?: 'string' | 'number' | 'bytes' | 'object' | 'resource'
@@ -42,7 +43,7 @@
}
</script>
{#if canEditResourceType}
{#if canEditResourceType || originalType == 'string[]' || originalType == 'object[]'}
<Label label="Items type">
<select
bind:value={selected}
@@ -67,10 +68,12 @@
>
<option value="string"> Items are strings</option>
<option value="enum">Items are strings from an enum</option>
<option value="object"> Items are objects (JSON)</option>
<option value="resource"> Items are resources</option>
<option value="number">Items are numbers</option>
<option value="bytes">Items are bytes</option>
{#if originalType != 'string[]'}
<option value="object"> Items are objects (JSON)</option>
<option value="resource"> Items are resources</option>
<option value="number">Items are numbers</option>
<option value="bytes">Items are bytes</option>
{/if}
</select>
</Label>
{:else if itemsType?.resourceType}
@@ -121,31 +124,33 @@
</div>
{/each}
</div>
<div class="flex flex-row mb-1 mt-2">
<Button
color="light"
variant="border"
size="sm"
on:click={() => {
if (itemsType?.enum) {
let enum_ = itemsType.enum
let choice = `choice ${enum_?.length ? enum_?.length + 1 : 1}`
itemsType.enum = itemsType.enum ? itemsType.enum.concat(choice) : [choice]
}
}}
>
<Plus size={14} />
</Button>
<Button
color="light"
variant="border"
size="sm"
btnClasses="ml-2"
on:click={() => itemsType?.enum && (itemsType.enum = undefined)}
>
Clear
</Button>
</div>
{#if canEditResourceType || originalType == 'string[]' || originalType == 'object[]'}
<div class="flex flex-row mb-1 mt-2">
<Button
color="light"
variant="border"
size="sm"
on:click={() => {
if (itemsType?.enum) {
let enum_ = itemsType.enum
let choice = `choice ${enum_?.length ? enum_?.length + 1 : 1}`
itemsType.enum = itemsType.enum ? itemsType.enum.concat(choice) : [choice]
}
}}
>
<Plus size={14} />
</Button>
<Button
color="light"
variant="border"
size="sm"
btnClasses="ml-2"
on:click={() => itemsType?.enum && (itemsType.enum = undefined)}
>
Clear
</Button>
</div>
{/if}
</label>
{/if}
@@ -0,0 +1,102 @@
<script lang="ts">
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
export let value: any
$: enabled = value != undefined
$: changeDomain(value?.['domain'], value?.['custom'])
function changeDomain(domain, custom) {
if (value) {
let baseUrl = custom ? `https://${domain}` : `https://${domain}.auth0.com`
value = {
...value,
login_config: {
auth_url: `${baseUrl}/authorize`,
token_url: `${baseUrl}/oauth/token`,
userinfo_url: `${baseUrl}/userinfo`,
scopes: ['openid', 'profile', 'email']
},
connect_config: {
auth_url: `${baseUrl}/authorize`,
token_url: `${baseUrl}/oauth/token`,
scopes: ['openid', 'profile', 'email']
}
}
}
}
</script>
<div class="flex flex-col gap-1">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium text-primary flex gap-4 items-center"
><div class="w-[120px]"><IconedResourceType name="auth0" after={true} /></div><Toggle
checked={enabled}
on:change={(e) => {
if (e.detail) {
value = { id: '', secret: '', domain: '', custom: false }
} else {
value = undefined
}
}}
/></label
>
{#if enabled}
<div class="p-2 rounded border">
<label class="block pb-2">
<div class="flex gap-2 items-end">
<div>
<ToggleButtonGroup bind:selected={value['custom']}>
<ToggleButton value={false} label={'Org'} />
<ToggleButton value={true} label="Custom" />
</ToggleButtonGroup>
</div>
<div class="grow">
<span class="text-primary font-semibold text-sm"
>{#if value['custom']}Custom ({'https://<domain>'}){:else}
Org ({'https://<your org>.auth0.com'}){/if}</span
>
<input type="text" placeholder="yourorg" bind:value={value['domain']} />
</div>
</div>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Client ID <Tooltip>Client ID credential of the auth0 service configuration</Tooltip
></span
>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Client Secret <Tooltip>Client Secret of the auth0 service configuration</Tooltip></span
>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
</label>
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
From your Admin page, setup a Windmill application<br />
Create a new application<br />
For "application type" select "Regular Web Application"<br />
Copy down the "Client ID" and "Client Secret" and paste them into the fields above <br />
Under "Application URIs", set the following:<br />
a. Application Login URI: `BASE_URL/user/login`<br />
b. Allowed Callback URLs: `BASE_URL/user/login_callback/auth0`<br />
c. Allowed Logout URLs: `BASE_URL/auth/logout`<br />
d. Allowed Web Origins: `BASE_URL`<br />
e. Allowed Origins (CORS): `BASE_URL`<br />
</div>
</CollapseLink>
</div>
{/if}
</div>
@@ -4,6 +4,7 @@
import OAuthSetting from '$lib/components/OAuthSetting.svelte'
import OktaSetting from './OktaSetting.svelte'
import Auth0Setting from './Auth0Setting.svelte'
import CloseButton from './common/CloseButton.svelte'
import KeycloakSetting from './KeycloakSetting.svelte'
import CustomSso from './CustomSso.svelte'
@@ -81,6 +82,7 @@
<OAuthSetting name="google" bind:value={oauths['google']} />
<OAuthSetting name="microsoft" bind:value={oauths['microsoft']} />
<OktaSetting bind:value={oauths['okta']} />
<Auth0Setting bind:value={oauths['auth0']} />
<OAuthSetting name="github" bind:value={oauths['github']} />
<OAuthSetting name="gitlab" bind:value={oauths['gitlab']} />
<OAuthSetting name="jumpcloud" bind:value={oauths['jumpcloud']} />
@@ -90,7 +92,7 @@
<KanidmSetting bind:value={oauths['kanidm']} />
<ZitadelSetting bind:value={oauths['zitadel']} />
{#each Object.keys(oauths) as k}
{#if !['authelia', 'authentik', 'google', 'microsoft', 'github', 'gitlab', 'jumpcloud', 'okta', 'keycloak', 'slack', 'kanidm', 'zitadel'].includes(k) && 'login_config' in oauths[k]}
{#if !['authelia', 'authentik', 'google', 'microsoft', 'github', 'gitlab', 'jumpcloud', 'okta', 'auth0', 'keycloak', 'slack', 'kanidm', 'zitadel'].includes(k) && 'login_config' in oauths[k]}
{#if oauths[k]}
<div class="flex flex-col gap-2 pb-4">
<div class="flex flex-row items-center gap-2">
+10 -5
View File
@@ -48,7 +48,7 @@
} from '$lib/relative_imports'
import Tooltip from './Tooltip.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import { initAllAiWorkspace } from './copilot/lib'
import { workspaceAIClients } from './copilot/lib'
import type { FlowPropPickerConfig, PropPickerContext } from './prop_picker'
import type { PickableProperties } from './flows/previousResults'
$: token = $page.url.searchParams.get('wm_token') ?? undefined
@@ -110,14 +110,19 @@
async function setCopilotInfo() {
if (workspace) {
initAllAiWorkspace(workspace)
workspaceAIClients.init(workspace)
try {
copilotInfo.set(await WorkspaceService.getCopilotInfo({ workspace }))
const info = await WorkspaceService.getCopilotInfo({ workspace })
copilotInfo.set({
...info,
ai_provider: info.ai_provider ?? 'openai'
})
} catch (err) {
copilotInfo.set({
ai_provider: '',
ai_provider: 'openai',
exists_ai_resource: false,
code_completion_enabled: false
code_completion_model: undefined,
ai_models: []
})
console.error('Could not get copilot info')
@@ -677,16 +677,22 @@
<img
alt="preview rendered"
class="w-auto h-full"
src={`/api/w/${workspaceId}/${appPath ? 'apps_u/load_image_preview/' + appPath : 'job_helpers/load_image_preview'}?file_key=${result.s3}` +
(result.storage ? `&storage=${result.storage}` : '')}
src={`/api/w/${workspaceId}/${
appPath
? 'apps_u/load_image_preview/' + appPath
: 'job_helpers/load_image_preview'
}?file_key=${result.s3}` + (result.storage ? `&storage=${result.storage}` : '')}
/>
</div>
{:else if result?.s3?.endsWith('.pdf')}
<div class="h-96 mt-2 border">
<PdfViewer
allowFullscreen
source={`/api/w/${workspaceId}/${appPath ? 'apps_u/load_image_preview/' + appPath : 'job_helpers/load_image_preview'}?file_key=${result.s3}` +
(result.storage ? `&storage=${result.storage}` : '')}
source={`/api/w/${workspaceId}/${
appPath
? 'apps_u/load_image_preview/' + appPath
: 'job_helpers/load_image_preview'
}?file_key=${result.s3}` + (result.storage ? `&storage=${result.storage}` : '')}
/>
</div>
{/if}
@@ -36,6 +36,7 @@
export let jsonEnabled: boolean = true
export let isAppInput: boolean = false
export let displayWebhookWarning: boolean = false
export let onlyMaskPassword: boolean = false
export let dndType: string | undefined = undefined
export let editTab:
| 'inputEditor'
@@ -282,6 +283,7 @@
schema={previewSchema ? previewSchema : schema}
{dndType}
{disableDnd}
{onlyMaskPassword}
bind:args
on:click={(e) => {
opened = e.detail
+2 -3
View File
@@ -172,7 +172,6 @@
import { initVim } from './monaco_keybindings'
import { buildWorkerDefinition } from '$lib/monaco_workers/build_workers'
import { parseTypescriptDeps } from '$lib/relative_imports'
import type { AiProviderTypes } from './copilot/lib'
// import EditorTheme from './EditorTheme.svelte'
@@ -640,7 +639,7 @@
textAfterPosition,
lang,
abortController,
aiProvider as AiProviderTypes
aiProvider
)
if (insertText) {
items = [
@@ -668,7 +667,7 @@
}
$: $copilotInfo.exists_ai_resource &&
$copilotInfo.code_completion_enabled &&
$copilotInfo.code_completion_model &&
$codeCompletionSessionEnabled &&
initialized &&
addCopilotSuggestions()
@@ -86,7 +86,6 @@
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
import type { AiProviderTypes } from './copilot/lib'
export let initialPath: string = ''
export let pathStoreInit: string | undefined = undefined
@@ -821,7 +820,7 @@
try {
push(history, $flowStore)
let module = stepOnly ? $copilotModulesStore[0] : $copilotModulesStore[idx]
const aiProvider = $copilotInfo.ai_provider as AiProviderTypes
const aiProvider = $copilotInfo.ai_provider
copilotLoading = true
copilotStatus = "Generating code for step '" + module.id + "'..."
@@ -20,6 +20,8 @@
})
if (jobs.length > 0) {
dispatch('select', { jobId: jobs[0].id, initial: true })
} else {
dispatch('nohistory')
}
}
@@ -169,6 +169,34 @@
}
$: selectedJobStep !== undefined && onSelectedJobStepChange()
async function loadIndividualStepsStates() {
dfs($flowStore.value.modules, async (module) => {
if ($flowStateStore[module.id]?.previewResult) {
return
}
const previousJobId = await JobService.listJobs({
workspace: $workspaceStore!,
scriptPathExact: (initialPath == '' ? $pathStore : initialPath) + '/' + module.id,
jobKinds: ['preview', 'script', 'flowpreview', 'flow'].join(','),
page: 1,
perPage: 1
})
if (previousJobId.length > 0) {
const getJobResult = await JobService.getCompletedJobResultMaybe({
workspace: $workspaceStore!,
id: previousJobId[0].id
})
if (getJobResult.result) {
$flowStateStore[module.id] = {
...($flowStateStore[module.id] ?? {}),
previewResult: getJobResult.result
}
}
}
})
}
</script>
<svelte:window on:keydown={onKeyDown} />
@@ -401,6 +429,9 @@
class="absolute top-[22px] right-2 border p-1.5 hover:bg-surface-hover rounded-md center-center"
>
<FlowHistoryJobPicker
on:nohistory={() => {
loadIndividualStepsStates()
}}
on:select={(e) => {
if (!currentJobId) {
currentJobId = jobId
@@ -439,11 +470,14 @@
{flowStateStore}
{jobId}
on:done={() => {
console.log('done')
$executionCount = $executionCount + 1
}}
on:jobsLoaded={({ detail }) => {
job = detail
if (initial) {
console.log('loading initial steps after initial job loaded')
loadIndividualStepsStates()
}
}}
bind:selectedJobStep
/>
@@ -121,9 +121,9 @@
/>
{/each}
{#if jobs?.length == 5}
<div class="text-left text-tertiary text-xs"
>... there may be more runs not displayed here as the limit is 5</div
>
<tr class="text-left text-tertiary text-xs w-full">
<td class="w-full px-2" colspan="3">limited to 5 runs</td>
</tr>
{/if}
</tbody>
{:else}
+6
View File
@@ -5,6 +5,7 @@
import Google from '$lib/components/icons/brands/Google.svelte'
import Microsoft from '$lib/components/icons/brands/Microsoft.svelte'
import Okta from '$lib/components/icons/brands/Okta.svelte'
import Auth0 from '$lib/components/icons/brands/Auth0.svelte'
import { OauthService, UserService, WorkspaceService } from '$lib/gen'
import { usersWorkspaceStore, workspaceStore, userStore } from '$lib/stores'
@@ -49,6 +50,11 @@
type: 'okta',
name: 'Okta',
icon: Okta
},
{
type: 'auth0',
name: 'Auth0',
icon: Auth0
}
] as const
@@ -204,7 +204,6 @@
</div>
{/if}</h4
>
{#if can_write && editDescription}
<div>
<div class="flex flex-row-reverse text-2xs text-tertiary -mt-1">GH Markdown</div>
@@ -43,6 +43,7 @@
export let initialFileKey: { s3: string } | undefined = undefined
let initialFileKeyInternalCopy: { s3: string }
export let selectedFileKey: { s3: string } | undefined = undefined
export let folderOnly = false
let csvSeparatorChar: string = ','
let csvHasHeader: boolean = true
@@ -369,6 +370,11 @@
let item_key = displayedFileKeys[index]
let item = allFilesByKey[item_key]
if (item.type === 'folder') {
if (folderOnly) {
selectedFileKey = {
s3: item_key
}
}
if (toggleCollapsed) {
item.collapsed = !item.collapsed
}
@@ -709,7 +715,9 @@
>
{#if !fromWorkspaceSettings}
<Button
disable={selectedFileKey === undefined || emptyString(selectedFileKey.s3)}
disabled={selectedFileKey === undefined ||
emptyString(selectedFileKey.s3) ||
(folderOnly && allFilesByKey[selectedFileKey.s3]?.type !== 'folder')}
on:click={selectAndClose}>Select</Button
>
{/if}
@@ -265,7 +265,10 @@
dispatch('click', argName)
}}
>
{#if typeof args == 'object' && schema?.properties[argName]}
{#if args && typeof args == 'object' && schema?.properties[argName]}
<!-- {argName}
{args == undefined}
{JSON.stringify(args?.[argName])} -->
{#if !hidden[argName]}
<ArgInput
on:change={() => {
@@ -119,7 +119,7 @@
{runnableId}
{runnableType}
on:select={(e) => {
dispatch('select', { payload: e.detail.args, type: 'history' })
dispatch('select', { payload: e.detail?.args, type: 'history' })
}}
/>
</FlowInputEditor>
+15 -7
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-svelte'
import { AlertTriangle, ChevronRight } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
export let label: string | undefined = undefined
export let tooltip: string | undefined = undefined
@@ -14,6 +15,7 @@
export let collapsable: boolean = false
export let collapsed: boolean = true
export let headless: boolean = false
export let animate: boolean = false
</script>
<div class={twMerge('w-full flex flex-col', wrapperClass)}>
@@ -27,11 +29,14 @@
>
{#if collapsable}
<button class="flex items-center gap-1" on:click={() => (collapsed = !collapsed)}>
{#if collapsed}
<ChevronRight size={16} />
{:else}
<ChevronDown size={16} />
{/if}
<ChevronRight
size={16}
class={twMerge(
'transition',
collapsed ? '' : 'rotate-90',
animate ? 'duration-200' : 'duration-0'
)}
/>
{label}
</button>
{:else}
@@ -58,7 +63,10 @@
</div>
{/if}
{#if !collapsable || !collapsed}
<div class={twMerge('grow min-h-0', $$props.class)}>
<div
class={twMerge('grow min-h-0', $$props.class)}
transition:slide={animate ? { duration: 200 } : { duration: 0 }}
>
<slot />
</div>
{/if}
@@ -120,6 +120,8 @@
}
if (e.detail == 'date-time') {
format = 'date-time'
} else if (format) {
format = undefined
}
if (e.detail == 'none') {
pattern = undefined
@@ -76,12 +76,29 @@
$: resolvedConfig.beforeIcon && beforeIconComponent && handleBeforeIcon()
$: resolvedConfig.afterIcon && afterIconComponent && handleAfterIcon()
function getIconSize() {
switch (resolvedConfig.size as 'xs' | 'sm' | 'md' | 'lg' | 'xl') {
case 'xs':
return 14
case 'sm':
return 16
case 'md':
return 20
case 'lg':
return 24
case 'xl':
return 26
default:
return 24
}
}
async function handleBeforeIcon() {
if (resolvedConfig.beforeIcon) {
beforeIconComponent = await loadIcon(
resolvedConfig.beforeIcon,
beforeIconComponent,
14,
getIconSize(),
undefined,
undefined
)
@@ -93,7 +110,7 @@
afterIconComponent = await loadIcon(
resolvedConfig.afterIcon,
afterIconComponent,
14,
getIconSize(),
undefined,
undefined
)
@@ -102,7 +102,7 @@
)}
>
<span class="mr-2 w-8 font-mono">{activeIndex === index ? '-' : '+'}</span>
{accordionInput?.value[index]?.header || `Header ${index}`}
{result[index]?.header || `Header ${index}`}
</button>
{#if activeIndex === index}
<div class="p-2 overflow-auto w-full">
@@ -15,6 +15,7 @@
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import { userStore } from '$lib/stores'
export let id: string
export let componentInput: AppInput | undefined
@@ -96,7 +97,7 @@
{result}
{requireHtmlApproval}
disableExpand={resolvedConfig?.hideDetails}
appPath={$appPath}
appPath={$userStore ? undefined : $appPath}
/>
</div>
</div>
@@ -16,6 +16,7 @@
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { userStore } from '$lib/stores'
export let id: string
export let initializing: boolean | undefined = false
@@ -23,7 +24,7 @@
export let configuration: RichConfigurations
export let render: boolean
const { app, worldStore, workspace } = getContext<AppViewerContext>('AppViewerContext')
const { app, worldStore, workspace, appPath } = getContext<AppViewerContext>('AppViewerContext')
const requireHtmlApproval = getContext<boolean | undefined>(IS_APP_PUBLIC_CONTEXT_KEY)
let resolvedConfig = initConfig(
@@ -115,6 +116,7 @@
{result}
{requireHtmlApproval}
disableExpand={resolvedConfig?.hideDetails}
appPath={$userStore ? undefined : $appPath}
/>
</div>
</div>
@@ -105,6 +105,7 @@
on:close
on:open
class={resolvedConfig.fillContainer ? 'w-full h-full' : ''}
usePointerDownOutside={true}
>
<svelte:fragment slot="trigger" let:trigger>
<div use:melt={trigger} class="w-full h-full">
@@ -145,7 +146,7 @@
</div>
</svelte:fragment>
<div class="flex flex-col w-full p-1 gap-2">
<div class="flex flex-col w-full p-1 gap-2 max-h-[50vh] overflow-y-auto">
{#if menuItems.length > 0}
{#each menuItems as actionButton, actionIndex (actionButton?.id)}
{#if actionButton.type == 'buttoncomponent'}
+1 -1
View File
@@ -248,7 +248,7 @@ declare function setValue(id: string, value: any): void;
*/
declare function setSelectedIndex(id: string, index: number): void;
/** close a drawer or modal
/** open a drawer or modal
* @param id component's id
*/
declare function open(id: string): void;
@@ -20,30 +20,24 @@
export let submittedText: string | undefined = undefined
export let defaultFile: string | undefined = undefined
export let disabled: boolean | undefined = undefined
export let folderOnly = false
const dispatch = createEventDispatcher()
let input: HTMLInputElement
export let files: File[] | undefined = undefined
type FileWithPath = File & { path?: string }
export let files: FileWithPath[] | undefined = undefined
async function onChange(fileList: FileList | null) {
async function onChange(fileList: FileWithPath[] | null) {
if (!fileList || !fileList.length) {
files = undefined
dispatch('change', files)
return
}
if (!multiple || !files) {
files = []
}
for (let i = 0; i < fileList.length; i++) {
const file = fileList.item(i)
if (file) {
files.push(file)
}
}
if (!files.length) {
files = undefined
if (multiple && files) {
files = [...files, ...fileList]
} else {
files = fileList
}
// Needs to be reset so the same file can be selected
@@ -86,15 +80,70 @@
}
}
function handleDrop(event: DragEvent) {
async function handleFile(fileEntry: FileSystemFileEntry): Promise<FileWithPath> {
return new Promise((resolve, reject) => {
fileEntry.file(resolve, reject)
})
}
async function handleDirectory(
dirEntry: FileSystemDirectoryEntry,
path: string
): Promise<FileWithPath[]> {
const files: FileWithPath[] = []
const dirReader = dirEntry.createReader()
async function readEntries() {
return new Promise<FileWithPath[]>((resolve) => {
dirReader.readEntries(async (entries) => {
if (entries.length === 0) {
resolve(files)
return
}
const filePromises = entries.map(async (entry) => {
return traverseFileTree(entry, path + dirEntry.name + '/')
})
const nestedFiles = await Promise.all(filePromises)
files.push(...nestedFiles.flat())
// readEntries only return up to 100 files
// continue reading if more files exist
resolve(await readEntries())
})
})
}
return readEntries()
}
async function traverseFileTree(entry: FileSystemEntry, path = ''): Promise<FileWithPath[]> {
if (entry.isFile) {
const file = await handleFile(entry as FileSystemFileEntry)
file.path = path + file.name
return [file]
} else if (entry.isDirectory) {
return handleDirectory(entry as FileSystemDirectoryEntry, path)
}
return []
}
async function handleDrop(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
if (event.dataTransfer.files && event.dataTransfer.files.length) {
if (!multiple && event.dataTransfer.files.length > 1) {
sendUserToast('Only one file can be uploaded at a time')
return
} else {
onChange(event.dataTransfer.files)
if (folderOnly) {
const item = event.dataTransfer.items[0]?.webkitGetAsEntry()
if (item) {
const files = await traverseFileTree(item, '')
onChange(files)
}
} else {
if (event.dataTransfer.files && event.dataTransfer.files.length) {
if (!multiple && event.dataTransfer.files.length > 1) {
sendUserToast('Only one file can be uploaded at a time')
return
} else {
onChange(Array.from(event.dataTransfer.files))
}
}
}
}
@@ -174,22 +223,22 @@
</div>
{:else}
<slot>
<span>Drag and drop {multiple ? 'files' : 'a file'}</span>
<span>Drag and drop {folderOnly ? 'a folder' : multiple ? 'files' : 'a file'}</span>
</slot>
{/if}
<input
class="!absolute !inset-0 !z-10 !opacity-0 !cursor-pointer"
type="file"
{...{ webkitdirectory: folderOnly }}
title={files ? `${files.length} file${files.length > 1 ? 's' : ''} chosen` : 'No file chosen'}
bind:this={input}
on:change={({ currentTarget }) => {
onChange(currentTarget.files)
onChange(currentTarget.files ? Array.from(currentTarget.files) : null)
}}
{accept}
{multiple}
{...$$restProps}
/>
{#if defaultFile}
<div class="w-full border-dashed border-t-2 text-2xs pt-1 text-tertiary mt-2">
Default file: <span class="text-blue-500">{defaultFile}</span>
@@ -14,7 +14,10 @@
export let acceptedFileTypes: string[] | undefined = ['*']
export let allowMultiple: boolean = true
export let containerText: string = allowMultiple
export let folderOnly = false
export let containerText: string = folderOnly
? 'Drag and drop a folder here or click to browse'
: allowMultiple
? 'Drag and drop files here or click to browse'
: 'Drag and drop a file here or click to browse'
export let customResourcePath: string | undefined = undefined
@@ -53,20 +56,61 @@
}
async function handleChange(files: File[] | undefined) {
for (const file of files ?? []) {
uploadFileToS3(file, file.name)
if (folderOnly) {
uniqueFolderPrefix = getRandomFolderPrefix()
uploadedFolderRoot = undefined
await Promise.all(
files?.map(async (file) => {
await uploadFileToS3(file, file.name)
}) ?? []
)
if (uploadedFolderRoot) {
dispatch('addition', {
path: uniqueFolderPrefix + uploadedFolderRoot,
filename: undefined
})
sendUserToast('Folder uploaded!')
}
} else {
for (const file of files ?? []) {
uploadFileToS3(file, file.name)
}
}
}
let activeUploads: { xhr: XMLHttpRequest; fileName: string }[] = []
async function uploadFileToS3(fileToUpload: File, fileToUploadKey: string) {
function getRandomFolderPrefix(): string {
const now = Date.now()
const randomNum = Math.floor(Math.random() * 65536)
return `windmill_uploads/upload_${now}_${randomNum}/`
}
let uniqueFolderPrefix = getRandomFolderPrefix()
let uploadedFolderRoot: string | undefined = undefined
async function uploadFileToS3(fileToUpload: File & { path?: string }, fileToUploadKey: string) {
if (fileToUpload === undefined || fileToUploadKey === undefined) {
return
}
let path: string | undefined = undefined
let fileExtension: string | undefined = undefined
if (randomFileKey) {
if (folderOnly) {
const relativePath = fileToUpload.webkitRelativePath || fileToUpload.path
if (!relativePath) {
throw new Error('Missing file relative path')
}
const rootFolder = relativePath.split('/')[0]
if (!rootFolder) {
throw new Error('Missing root folder')
}
if (uploadedFolderRoot && rootFolder !== uploadedFolderRoot) {
throw new Error('Uploading a file in a different root folder than the previous files')
} else {
uploadedFolderRoot = rootFolder
}
path = uniqueFolderPrefix + relativePath
} else if (randomFileKey) {
fileExtension = fileToUpload.name.split('.').pop()
if (emptyString(fileExtension)) {
fileExtension = undefined
@@ -219,8 +263,10 @@
})
return
}
dispatch('addition', { path: uploadData.path, filename: fileToUpload.name })
sendUserToast('File upload finished!')
if (!folderOnly) {
dispatch('addition', { path: uploadData.path, filename: fileToUpload.name })
sendUserToast('File uploaded!')
}
uploadData.progress = 100
$fileUploads = $fileUploads.map((fileUpload) => {
@@ -442,6 +488,7 @@
</div>
{:else}
<FileInput
{folderOnly}
{disabled}
accept={acceptedFileTypes?.join(',')}
multiple={allowMultiple}
@@ -12,7 +12,7 @@
}
</script>
{#if $copilotInfo.exists_ai_resource && $copilotInfo.code_completion_enabled}
{#if $copilotInfo.exists_ai_resource && $copilotInfo.code_completion_model}
<Toggle
size="xs"
bind:checked={$codeCompletionSessionEnabled}
@@ -1,13 +1,14 @@
<script lang="ts">
import { ExternalLink, Wand2 } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { getNonStreamingCompletion, type AiProviderTypes } from './lib'
import { getNonStreamingCompletion } from './lib'
import Popup from '../common/popup/Popup.svelte'
import { sendUserToast } from '$lib/toast'
import { copilotInfo } from '$lib/stores'
import { base } from '$lib/base'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import type { AIProvider } from '$lib/gen'
export let schedule: string
export let cronVersion: string
@@ -56,7 +57,7 @@
const response = await getNonStreamingCompletion(
messages,
abortController,
aiProvider as AiProviderTypes
aiProvider as AIProvider
)
if (response.startsWith('ERROR:')) {
@@ -1,7 +1,7 @@
<script lang="ts">
import { Check, Loader2, Wand2 } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { getNonStreamingCompletion, type AiProviderTypes } from './lib'
import { getNonStreamingCompletion } from './lib'
import { sendUserToast } from '$lib/toast'
import type { Flow, InputTransform } from '$lib/gen'
import ManualPopover from '../ManualPopover.svelte'
@@ -71,7 +71,7 @@ Only output the expression, do not explain or discuss.`
}
],
abortController,
aiProvider as AiProviderTypes
aiProvider
)
} catch (err) {
if (!abortController.signal.aborted) {
@@ -1,5 +1,5 @@
<script lang="ts">
import { getCompletion, getResponseFromEvent, type AiProviderTypes } from './lib'
import { getCompletion, getResponseFromEvent } from './lib'
import { isInitialCode } from '$lib/script_helpers'
import { Check, Loader2, Wand2 } from 'lucide-svelte'
import { copilotInfo, metadataCompletionEnabled } from '$lib/stores'
@@ -118,7 +118,7 @@ Generate a description for the flow below:
content: config.user.replace(`{${config.placeholderName}}`, placeholderContent)
}
]
const aiProvider = $copilotInfo.ai_provider as AiProviderTypes
const aiProvider = $copilotInfo.ai_provider
const response = await getCompletion(messages, abortController, aiProvider)
generatedContent = ''
for await (const chunk of response) {
@@ -1,7 +1,7 @@
<script lang="ts">
import { Wand2 } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { getNonStreamingCompletion, type AiProviderTypes } from './lib'
import { getNonStreamingCompletion } from './lib'
import { sendUserToast } from '$lib/toast'
import { createEventDispatcher, getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
@@ -68,7 +68,7 @@ Only return the expression without any wrapper. Do not explain or discuss.`
}
],
abortController,
aiProvider as AiProviderTypes
aiProvider
)
dispatch('setExpr', result)
@@ -2,7 +2,7 @@
import { base } from '$lib/base'
import { Button } from '../common'
import { getNonStreamingCompletion, type AiProviderTypes } from './lib'
import { getNonStreamingCompletion } from './lib'
import { sendUserToast } from '$lib/toast'
import Popup from '../common/popup/Popup.svelte'
import { copilotInfo } from '$lib/stores'
@@ -40,7 +40,7 @@
}
],
abortController,
aiProvider as AiProviderTypes
aiProvider
)
dispatch('gen', { res: res, prompt: funcDesc })
funcDesc = ''
@@ -2,7 +2,7 @@
import { base } from '$lib/base'
import { Button } from '../common'
import { SUPPORTED_LANGUAGES, copilot, type AiProviderTypes } from './lib'
import { SUPPORTED_LANGUAGES, copilot } from './lib'
import type { SupportedLanguage } from '$lib/common'
import { sendUserToast } from '$lib/toast'
import type Editor from '../Editor.svelte'
@@ -40,7 +40,7 @@
try {
genLoading = true
abortController = new AbortController()
const aiProvider = $copilotInfo.ai_provider as AiProviderTypes
const aiProvider = $copilotInfo.ai_provider
await copilot(
{
language: lang,
@@ -53,7 +53,7 @@
generatedCode,
abortController,
aiProvider,
generatedExplanation,
generatedExplanation
)
setupDiff()
diffEditor?.setModified($generatedCode)
@@ -2,18 +2,18 @@
import { base } from '$lib/base'
import { Button } from '../common'
import {
MAX_SCHEMA_LENGTH,
SUPPORTED_LANGUAGES,
addThousandsSeparator,
copilot,
type AiProviderTypes
} from './lib'
import { MAX_SCHEMA_LENGTH, SUPPORTED_LANGUAGES, addThousandsSeparator, copilot } from './lib'
import type { SupportedLanguage } from '$lib/common'
import { sendUserToast } from '$lib/toast'
import type Editor from '../Editor.svelte'
import Popup from '../common/popup/Popup.svelte'
import { dbSchemas, copilotInfo, type DBSchema, workspaceStore } from '$lib/stores'
import {
dbSchemas,
copilotInfo,
type DBSchema,
workspaceStore,
copilotSessionModel
} from '$lib/stores'
import type DiffEditor from '../DiffEditor.svelte'
import { scriptLangToEditorLang } from '$lib/scripts'
import type SimpleEditor from '../SimpleEditor.svelte'
@@ -25,16 +25,7 @@
import LoadingIcon from '../apps/svelte-select/lib/LoadingIcon.svelte'
import { sleep } from '$lib/utils'
import { autoPlacement } from '@floating-ui/core'
import {
AlertTriangle,
Ban,
Bot,
Check,
ExternalLink,
HistoryIcon,
Wand2,
X
} from 'lucide-svelte'
import { AlertTriangle, Ban, Check, ExternalLink, HistoryIcon, Wand2, X } from 'lucide-svelte'
import { fade } from 'svelte/transition'
import { isInitialCode } from '$lib/script_helpers'
import { twMerge } from 'tailwind-merge'
@@ -74,7 +65,7 @@
return
}
savePrompt()
const aiProvider = $copilotInfo.ai_provider as AiProviderTypes
const aiProvider = $copilotInfo.ai_provider
try {
genLoading = true
blockPopupOpen = true
@@ -248,6 +239,16 @@
}
}
function checkForInvalidModel() {
if (
!$copilotSessionModel ||
($copilotSessionModel && !$copilotInfo.ai_models.includes($copilotSessionModel))
) {
$copilotSessionModel = $copilotInfo.ai_models[0]
}
}
$: $copilotInfo && checkForInvalidModel()
onDestroy(() => {
abortController?.abort()
})
@@ -399,21 +400,27 @@
</div>
{:else if $copilotInfo.exists_ai_resource}
<div class="flex flex-col gap-4">
<div class="flex flex-row justify-between items-center">
<div class="flex flex-row justify-between items-center w-96 gap-2">
<ToggleButtonGroup class="w-auto shrink-0" bind:selected={mode}>
<ToggleButton value={'gen'} label="Generate from scratch" small light />
<ToggleButton value={'edit'} label="Edit existing code" small light />
</ToggleButtonGroup>
<div class="text-[0.6rem] text-secondary opacity-60 flex flex-row items-center gap-0.5">
{#if $copilotInfo.ai_provider === 'openai'}
GPT-4o
{:else if $copilotInfo.ai_provider === 'anthropic'}
Claude-3.5
{:else}
Codestral
<div class="min-w-0">
{#if $copilotInfo.ai_models.length > 1}
<select
bind:value={$copilotSessionModel}
class="!text-xs !pr-5 !bg-[right_center] overflow-ellipsis text-right !border-none !shadow-none"
>
{#each $copilotInfo.ai_models as model}
<option value={model} class="pr-4">{model}</option>
{/each}
</select>
{:else if $copilotInfo.ai_models.length === 1}
<div class="text-xs whitespace-nowrap overflow-hidden overflow-ellipsis">
{$copilotInfo.ai_models[0]}
</div>
{/if}
<Bot size={14} />
</div>
</div>
<div class="flex w-96 items-start">
@@ -1,7 +1,7 @@
<script lang="ts">
import { Check, Loader2, Wand2 } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { getNonStreamingCompletion, type AiProviderTypes } from './lib'
import { getNonStreamingCompletion } from './lib'
import { sendUserToast } from '$lib/toast'
import type { Flow, InputTransform } from '$lib/gen'
import ManualPopover from '../ManualPopover.svelte'
@@ -116,7 +116,7 @@ Only return the expression without any wrapper.`
}
],
abortController,
aiProvider as AiProviderTypes
aiProvider
)
if (
@@ -7,7 +7,7 @@
import type { FlowEditorContext } from '../flows/types'
import type { PickableProperties } from '../flows/previousResults'
import { getContext } from 'svelte'
import { getNonStreamingCompletion, type AiProviderTypes } from './lib'
import { getNonStreamingCompletion } from './lib'
import { sendUserToast } from '$lib/toast'
import Button from '../common/button/Button.svelte'
import type { FlowCopilotContext } from './flow'
@@ -92,7 +92,7 @@ input_name2: expression2
}
],
abortController,
aiProvider as AiProviderTypes
aiProvider
)
parsedInputs = generatedContent.split('\n').map((x) => x.split(': '))
@@ -1,10 +1,13 @@
<script lang="ts">
import type { AIProvider } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Button from '../common/button/Button.svelte'
import { testKey, type AiProviderTypes } from './lib'
import { testKey } from './lib'
export let disabled = false
export let apiKey: string | undefined = undefined
export let aiProvider: AiProviderTypes
export let resourcePath: string | undefined = undefined
export let aiProvider: AIProvider
export let model: string
let loading = false
</script>
@@ -21,8 +24,10 @@
setTimeout(() => {
abortController.abort()
}, 10000)
await testKey({
apiKey,
resourcePath,
messages: [
{
role: 'user',
@@ -30,7 +35,8 @@
}
],
abortController,
aiProvider
aiProvider,
model
})
sendUserToast('Valid key')
} catch (err) {
@@ -42,5 +48,11 @@
} finally {
loading = false
}
}}>Test key</Button
}}
>
{#if apiKey}
Test key
{:else}
Test
{/if}
</Button>
@@ -1,6 +1,7 @@
import type { ChatCompletionMessageParam } from 'openai/resources/chat/index.mjs'
import { getNonStreamingCompletion, type AiProviderTypes } from './lib'
import type { AIProvider } from '$lib/gen'
import { codeCompletionLoading } from '$lib/stores'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/index.mjs'
import { getNonStreamingCompletion } from './lib'
const systemPrompt = `You are a code completion assistant, return the code that should go instead of the <completion_tokens>.
@@ -74,7 +75,7 @@ export async function editorCodeCompletion(
after: string,
lang: string,
abortController: AbortController,
aiProvider: AiProviderTypes
aiProvider: AIProvider
) {
codeCompletionLoading.set(true)
const messages: ChatCompletionMessageParam[] = [
+8 -12
View File
@@ -1,21 +1,17 @@
import {
type FlowModule,
ScriptService,
type RawScript,
type PathScript,
type AIProvider,
type FlowModule,
type InputTransform,
type PathScript,
type RawScript,
type Script
} from '$lib/gen'
import {
addResourceTypes,
deltaCodeCompletion,
getNonStreamingCompletion,
type AiProviderTypes
} from './lib'
import { scriptLangToEditorLang } from '$lib/scripts'
import type { Writable } from 'svelte/store'
import type Editor from '../Editor.svelte'
import type { Drawer } from '../common'
import { scriptLangToEditorLang } from '$lib/scripts'
import { addResourceTypes, deltaCodeCompletion, getNonStreamingCompletion } from './lib'
export type FlowCopilotModule = {
id: string
@@ -259,7 +255,7 @@ export async function stepCopilot(
| undefined,
isFirstInLoop: boolean,
abortController: AbortController,
aiProvider: AiProviderTypes
aiProvider: AIProvider
) {
if (module.source !== 'custom') {
throw new Error('Not a custom module')
@@ -328,7 +324,7 @@ export async function glueCopilot(
},
isFirstInLoop: boolean,
abortController: AbortController,
aiProvider: AiProviderTypes
aiProvider: AIProvider
) {
const { prevCode, prevLang } = await getPreviousStepContent(pastModule, workspace)
+253 -248
View File
@@ -1,56 +1,130 @@
import { OpenAI } from 'openai'
import { OpenAPI, ResourceService, type Script } from '../../gen'
import type { Writable } from 'svelte/store'
import type { AIProvider } from '$lib/gen'
import {
copilotInfo,
copilotSessionModel,
type DBSchema,
type GraphqlSchema,
type SQLSchema
} from '$lib/stores'
import { Anthropic } from '@anthropic-ai/sdk'
import type { DBSchema, GraphqlSchema, SQLSchema } from '$lib/stores'
import { formatResourceTypes } from './utils'
import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts'
import { Mistral } from '@mistralai/mistralai'
import { buildClientSchema, printSchema } from 'graphql'
import { OpenAI } from 'openai'
import type {
ChatCompletionCreateParamsStreaming,
ChatCompletionMessageParam
} from 'openai/resources/index.mjs'
import { get, type Writable } from 'svelte/store'
import { OpenAPI, ResourceService, type Script } from '../../gen'
import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts'
import { formatResourceTypes } from './utils'
import type { MessageCreateParams, MessageParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import type { ChatCompletionRequest } from '@mistralai/mistralai/models/components/chatcompletionrequest'
import type {
SystemMessage,
UserMessage,
AssistantMessage,
ToolMessage,
CompletionEvent,
ContentChunk
ContentChunk,
SystemMessage,
ToolMessage,
UserMessage
} from '@mistralai/mistralai/models/components'
import type { ChatCompletionRequest } from '@mistralai/mistralai/models/components/chatcompletionrequest'
export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts))
export type AiProviderTypes = 'openai' | 'anthropic' | 'mistral'
interface AiProvider {
init: (workspace: string, updateClient: boolean, token?: string) => void
export const AI_DEFAULT_MODELS: Record<AIProvider, string[]> = {
openai: ['gpt-4o', 'gpt-4o-mini'],
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest'],
mistral: ['codestral-latest'],
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'],
openrouter: ['meta-llama/llama-3.2-3b-instruct:free'],
customai: []
}
class WorkspacedMistral implements AiProvider {
private client: Mistral | undefined
export const OPENAI_COMPATIBLE_BASE_URLS = {
groq: 'https://api.groq.com/openai/v1',
openrouter: 'https://openrouter.ai/api/v1',
deepseek: 'https://api.deepseek.com/v1'
} as const
init(workspace: string, updateClient: boolean, token?: string) {
if (!this.client || updateClient) {
this.client = initWorkspaceAiProvider(workspace, 'mistral', token) as unknown as Mistral
}
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
private mistralClient: Mistral | undefined
init(workspace: string) {
this.initOpenai(workspace)
this.initAnthropic(workspace)
this.initMistral(workspace)
}
getClient() {
if (!this.client) {
throw new Error('AnthropicAi not initialized')
private getBaseURL(workspace: string) {
return `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
}
private initOpenai(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.openaiClient = new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
}
private initAnthropic(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.anthropicClient = new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
}
private initMistral(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.mistralClient = new Mistral({
serverURL: baseURL
})
}
getOpenaiClient() {
if (!this.openaiClient) {
throw new Error('OpenAI not initialized')
}
return this.client
return this.openaiClient
}
getAnthropicClient() {
if (!this.anthropicClient) {
throw new Error('Anthropic not initialized')
}
return this.anthropicClient
}
getMistralClient() {
if (!this.mistralClient) {
throw new Error('Mistral not initialized')
}
return this.mistralClient
}
}
export namespace MistralAi {
export let workspace = new WorkspacedMistral()
export const workspaceAIClients = new WorkspacedAIClients()
const DEFAULT_COMPLETION_CONFIG: ChatCompletionCreateParamsStreaming = {
model: '',
max_tokens: 8000, //TODO: make this dynamic
temperature: 0,
seed: 42,
stream: true,
messages: []
}
namespace MistralAI {
export const mistralConfig: ChatCompletionRequest = {
temperature: 0,
model: null,
@@ -78,30 +152,11 @@ export namespace MistralAi {
}
}
class WorkspacedAnthropic implements AiProvider {
private client: Anthropic | undefined
init(workspace: string, updateClient: boolean, token: string | undefined = undefined) {
if (!this.client || updateClient) {
this.client = initWorkspaceAiProvider(workspace, 'anthropic', token) as unknown as Anthropic
}
}
getClient() {
if (!this.client) {
throw new Error('AnthropicAi not initialized')
}
return this.client
}
}
export namespace AnthropicAi {
export let workspace = new WorkspacedAnthropic()
export namespace AnthropicAI {
export const config: MessageCreateParams = {
temperature: 0,
max_tokens: 8192,
model: 'claude-3-5-sonnet-20241022',
model: '',
messages: []
}
@@ -135,30 +190,11 @@ export namespace AnthropicAi {
}
}
class WorkspacedOpenai implements AiProvider {
private client: OpenAI | undefined
init(workspace: string, updateClient: boolean, token: string | undefined = undefined) {
if (!this.client || updateClient) {
this.client = initWorkspaceAiProvider(workspace, 'openai', token) as unknown as OpenAI
}
}
getClient() {
if (!this.client) {
throw new Error('OpenAI not initialized')
}
return this.client
}
}
export namespace OpenAi {
export let workspace = new WorkspacedOpenai()
namespace OpenAi {
export const openaiConfig: ChatCompletionCreateParamsStreaming = {
temperature: 0,
max_tokens: 16384,
model: 'gpt-4o-2024-08-06',
model: '',
seed: 42,
stream: true,
messages: []
@@ -169,125 +205,38 @@ export namespace OpenAi {
}
}
export function initAllAiWorkspace(workspace: string, updateClient: boolean = false) {
OpenAi.workspace.init(workspace, updateClient)
AnthropicAi.workspace.init(workspace, updateClient)
MistralAi.workspace.init(workspace, updateClient)
}
function initWorkspaceAiProvider(
workspace: string,
aiProvider: AiProviderTypes,
token: string | undefined = undefined
): Anthropic | OpenAI | Mistral {
const baseURL = `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
let client
switch (aiProvider) {
case 'openai': {
client = new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: token ? `Bearer ${token}` : ''
},
dangerouslyAllowBrowser: true
})
break
}
case 'anthropic': {
client = new Anthropic({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: token ? `Bearer ${token}` : ''
},
dangerouslyAllowBrowser: true
})
break
}
case 'mistral': {
client = new Mistral({
serverURL: baseURL
})
}
}
return client
}
export async function testKey({
apiKey,
resourcePath,
model,
abortController,
messages,
aiProvider
}: {
apiKey?: string
resourcePath?: string
model: string | undefined
messages: ChatCompletionMessageParam[]
abortController: AbortController
aiProvider: AiProviderTypes
aiProvider: AIProvider
}) {
if (apiKey) {
switch (aiProvider) {
case 'openai': {
const openai = new OpenAI({
apiKey,
dangerouslyAllowBrowser: true
})
await openai.chat.completions.create(
{
...OpenAi.openaiConfig,
messages,
stream: false
},
{
signal: abortController.signal
}
)
break
}
case 'anthropic': {
const anthropic = new Anthropic({
apiKey,
dangerouslyAllowBrowser: true
})
const [, anthropicMessages] = AnthropicAi.getSystemPromptAndArrayMessages(messages)
await anthropic.messages.create(
{
...AnthropicAi.config,
messages: anthropicMessages,
stream: false
},
{
signal: abortController.signal
}
)
break
}
case 'mistral': {
const mistral = new Mistral({
apiKey
})
await mistral.chat.complete(
{
...MistralAi.mistralConfig,
model: 'codestral-latest',
stream: false,
messages: messages as MistralAi.MistralParamsMessage[]
},
{
fetchOptions: {
signal: abortController.signal,
headers: {
'content-type': 'application/json'
}
}
}
)
break
}
}
} else {
await getNonStreamingCompletion(messages, abortController, aiProvider, undefined, true)
if (!apiKey && !resourcePath) {
throw new Error('API key or resource path is required')
}
const modelToTest = model ?? AI_DEFAULT_MODELS[aiProvider][0]
if (!modelToTest) {
throw new Error('Missing a model to test')
}
await getNonStreamingCompletion(
messages,
abortController,
aiProvider,
apiKey,
resourcePath,
modelToTest
)
}
interface BaseOptions {
@@ -410,7 +359,9 @@ function addDBSChema(scriptOptions: CopilotOptions, prompt: string) {
const { dbSchema, language } = scriptOptions
if (
dbSchema &&
['postgresql', 'mysql', 'snowflake', 'bigquery', 'mssql', 'graphql', 'oracledb'].includes(language) && // make sure we are using a SQL/query language
['postgresql', 'mysql', 'snowflake', 'bigquery', 'mssql', 'graphql', 'oracledb'].includes(
language
) && // make sure we are using a SQL/query language
language === dbSchema.lang // make sure we are using the same language as the schema
) {
let { stringified } = dbSchema
@@ -467,66 +418,109 @@ const PROMPTS_CONFIGS = {
export async function getNonStreamingCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
aiProvider: AiProviderTypes,
model = OpenAi.openaiConfig.model,
noCache?: boolean
aiProvider: AIProvider,
apiKey?: string, // testing API KEY directly from the frontend
resourcePath?: string, // testing resource path passed as a header to the backend proxy
forceModel?: string
) {
let response: string | undefined = ''
const queryOptions = {
query: {
no_cache: noCache
},
let model = forceModel
if (!model) {
model = get(copilotSessionModel)
let info = get(copilotInfo)
const { ai_models: aiModels } = info
if (!model || !aiModels.includes(model)) {
console.warn('Invalid model, using default model:', aiModels[0])
model = aiModels[0]
}
}
if (!model) {
throw new Error('No model found')
}
const fetchOptions: {
signal: AbortSignal
headers?: Record<string, string>
} = {
signal: abortController.signal
}
switch (aiProvider) {
case 'openai': {
const openaiClient = OpenAi.workspace.getClient()
const completion = await openaiClient.chat.completions.create(
{
...OpenAi.openaiConfig,
messages,
stream: false,
model
},
queryOptions
)
response = completion.choices[0]?.message.content || ''
break
if (resourcePath) {
fetchOptions.headers = {
'X-Resource-Path': resourcePath
}
}
switch (aiProvider) {
case 'anthropic': {
const anthropicClient = AnthropicAi.workspace.getClient()
const [system, anthropicMessages] = AnthropicAi.getSystemPromptAndArrayMessages(messages)
const anthropicClient = apiKey
? new Anthropic({
apiKey,
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getAnthropicClient()
const [system, anthropicMessages] = AnthropicAI.getSystemPromptAndArrayMessages(messages)
const message = await anthropicClient.messages.create(
{
...AnthropicAi.config,
...AnthropicAI.config,
system,
model,
messages: anthropicMessages,
stream: false
},
queryOptions
fetchOptions
)
response = message.content[0].type === 'text' ? message.content[0].text : ''
break
}
case 'mistral': {
const mistralClient = MistralAi.workspace.getClient()
const mistralClient = apiKey
? new Mistral({
apiKey
})
: workspaceAIClients.getMistralClient()
const message = await mistralClient.chat.complete(
{
...MistralAi.mistralConfig,
model: 'codestral-latest',
...MistralAI.mistralConfig,
model,
stream: false,
messages: messages as MistralAi.MistralParamsMessage[]
messages: messages as MistralAI.MistralParamsMessage[]
},
{
fetchOptions: {
signal: abortController.signal,
cache: 'no-store'
}
fetchOptions: fetchOptions
}
)
response = MistralAi.retrieveTextValue(message.choices && message.choices[0].message.content)
response = MistralAI.retrieveTextValue(message.choices && message.choices[0].message.content)
break
}
default: {
if (aiProvider === 'customai' && apiKey) {
throw new Error('Cannot test API key for Custom AI, only resource path is supported')
}
const baseURL = OPENAI_COMPATIBLE_BASE_URLS[aiProvider]
if (apiKey && aiProvider !== 'openai' && !baseURL) {
throw new Error('No base URL for this provider: ' + aiProvider)
}
const openaiClient = apiKey
? new OpenAI({
apiKey,
baseURL,
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(
{
...(aiProvider === 'openai' ? OpenAi.openaiConfig : DEFAULT_COMPLETION_CONFIG),
messages,
model,
stream: false
},
fetchOptions
)
response = completion.choices[0]?.message.content || ''
}
}
return response
}
@@ -534,17 +528,30 @@ export async function getNonStreamingCompletion(
export async function getCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
aiProvider: AiProviderTypes,
model = OpenAi.openaiConfig.model
aiProvider: AIProvider
) {
let model = get(copilotSessionModel)
let info = get(copilotInfo)
const { ai_models: aiModels } = info
if (!model || !aiModels.includes(model)) {
console.warn('Invalid model, using default model:', aiModels[0])
model = aiModels[0]
}
if (!model) {
throw new Error('No model found')
}
switch (aiProvider) {
case 'anthropic': {
const anthropicClient = AnthropicAi.workspace.getClient()
const [system, anthropicMessages] = AnthropicAi.getSystemPromptAndArrayMessages(messages)
const anthropicClient = workspaceAIClients.getAnthropicClient()
const [system, anthropicMessages] = AnthropicAI.getSystemPromptAndArrayMessages(messages)
const completion = await anthropicClient.messages.create(
{
...AnthropicAi.config,
...AnthropicAI.config,
model,
system,
messages: anthropicMessages,
stream: true
@@ -553,13 +560,29 @@ export async function getCompletion(
)
return completion
}
case 'openai': {
const openaiClient = OpenAi.workspace.getClient()
case 'mistral': {
const mistralClient = workspaceAIClients.getMistralClient()
const message = await mistralClient.chat.stream(
{
...MistralAI.mistralConfig,
model,
messages: messages as MistralAI.MistralParamsMessage[]
},
{
fetchOptions: {
signal: abortController.signal
}
}
)
return message
}
default: {
const openaiClient = workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(
{
...OpenAi.openaiConfig,
messages,
model
...(aiProvider === 'openai' ? OpenAi.openaiConfig : DEFAULT_COMPLETION_CONFIG),
model,
messages
},
{
signal: abortController.signal
@@ -567,23 +590,6 @@ export async function getCompletion(
)
return completion
}
case 'mistral': {
const mistralClient = MistralAi.workspace.getClient()
const message = await mistralClient.chat.stream(
{
...MistralAi.mistralConfig,
model: 'codestral-latest',
messages: messages as MistralAi.MistralParamsMessage[]
},
{
fetchOptions: {
signal: abortController.signal,
cache: 'no-store'
}
}
)
return message
}
}
}
@@ -592,21 +598,20 @@ export function getResponseFromEvent(
| Anthropic.Messages.RawMessageStreamEvent
| OpenAI.Chat.Completions.ChatCompletionChunk
| CompletionEvent,
aiProvider: AiProviderTypes
aiProvider: AIProvider
): string {
switch (aiProvider) {
case 'openai': {
const messages = part as OpenAI.Chat.Completions.ChatCompletionChunk
return OpenAi.retrieveTextValue(messages)
}
case 'anthropic': {
const messages = part as Anthropic.Messages.RawMessageStreamEvent
return AnthropicAi.retrieveTextValue(messages)
return AnthropicAI.retrieveTextValue(messages)
}
case 'mistral': {
const messages = part as CompletionEvent
return MistralAi.retrieveTextValue(messages.data.choices[0].delta.content)
return MistralAI.retrieveTextValue(messages.data.choices[0].delta.content)
}
default:
const messages = part as OpenAI.Chat.Completions.ChatCompletionChunk
return OpenAi.retrieveTextValue(messages)
}
}
@@ -614,7 +619,7 @@ export async function copilot(
scriptOptions: CopilotOptions,
generatedCode: Writable<string>,
abortController: AbortController,
aiProvider: AiProviderTypes,
aiProvider: AIProvider,
generatedExplanation?: Writable<string>
) {
const { prompt, systemPrompt } = await getPrompts(scriptOptions)
@@ -704,7 +709,7 @@ export async function deltaCodeCompletion(
messages: ChatCompletionMessageParam[],
generatedCodeDelta: Writable<string>,
abortController: AbortController,
aiProvider: AiProviderTypes
aiProvider: AIProvider
) {
const completion = await getCompletion(messages, abortController, aiProvider)
@@ -74,6 +74,7 @@
on:updateSchema
on:testWithArgs
{newItem}
alwaysOpened={true}
/>
{:else}
<div>
@@ -304,7 +304,7 @@
function resetArgs() {
if (!previewSchema) {
previewArguments = undefined
// previewArguments = undefined
savedPreviewArgs = undefined
}
}
@@ -122,8 +122,13 @@
<Toggle
options={{
right: 'Continue on disapproval/timeout',
rightTooltip:
'Instead of failing the flow and bubbling up the error, continue to the next step which would allow to put a branchone right after to handle both cases separately. If any disapproval/timeout event is received, the resume payload will be similar to every error result in Winmdill, an object containing an `error` field which you can use to distinguish between approvals and disapproval/timeouts'
rightTooltip: `Instead of failing the flow and bubbling up the error, continue to the next step which would allow to put a branchone right after to handle both cases separately.
If any disapproval/timeout event is received, the resume payload will be similar to every error result in Windmill, an object containing an "error" field which you can use
to distinguish between approvals and disapproval/timeouts.
We recommend using the expr "resume?.error" to handle null payload values.
To filter timeout, use "resume?.error?.name === "SuspendedTimedOut"
To filter disapproval, use "resume?.error?.name === "SuspendedDisapproved"`
}}
checked={Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
disabled={!Boolean(flowModule.suspend)}
@@ -133,6 +138,14 @@
}
}}
/>
{#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
<Alert type="info" title="Continue on disapproval/timeout">
We recommend using the expr <code>resume?.error</code> to handle null payload values.
<br />
To filter timeout, use <code>resume?.error?.name === "SuspendedTimedOut"</code>. <br />
To filter disapproval, use <code>resume?.error?.name === "SuspendedDisapproved"</code>
</Alert>
{/if}
</div>
{:else if suspendTabSelected === 'permissions'}
<div class="flex flex-col mt-4 gap-4">
@@ -62,7 +62,7 @@
{/if}
<Menubar let:createMenu>
<Menu {createMenu} let:item placement="bottom" bind:this={menu}>
<Menu {createMenu} let:item placement="bottom" bind:this={menu} usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<button
title="Pick an iteration"
@@ -88,7 +88,7 @@ function getFlowInput(
}
} else {
let parentFlowInput = getFlowInput(parentModules, flowState, args, schema)
if (parentModule.value.type === 'forloopflow') {
if (parentModule.value.type === 'forloopflow' || parentModule.value.type === 'whileloopflow') {
let parentFlowInputIter = { ...parentFlowInput }
if (parentFlowInputIter.hasOwnProperty('iter')) {
parentFlowInputIter['iter_parent'] = parentFlowInputIter['iter']
@@ -263,9 +263,8 @@ declare const results = ${JSON.stringify(results)};
*/
declare const previous_result: ${previousId ? JSON.stringify(results[previousId]) : 'any'};
${
resume
? `
${resume
? `
/**
* resume payload
*/
@@ -276,8 +275,8 @@ declare const resume: any
*/
declare const approvers: string
`
: ''
}
: ''
}
`
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { FlowService, type FlowModule } from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { createEventDispatcher, getContext, onDestroy, onMount, setContext } from 'svelte'
import { createEventDispatcher, getContext, onDestroy, onMount, setContext, tick } from 'svelte'
import { get, writable, type Writable } from 'svelte/store'
import '@xyflow/svelte/dist/style.css'
@@ -273,7 +273,7 @@
return false
}
function updateStores() {
async function updateStores() {
if (graph.error) {
return
}
@@ -281,6 +281,7 @@
$nodes = layoutNodes(newGraph.nodes)
$edges = newGraph.edges
await tick()
height = Math.max(...$nodes.map((n) => n.position.y + NODE.height + 100), minHeight)
}
@@ -0,0 +1,18 @@
<svg
version="1.2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1594 1595"
width="24"
height="24"
>
<title>auth0ddd-svg</title>
<style>
.s0 {
fill: #000000;
}
</style>
<path
class="s0"
d="M160.183716,48.171844 C209.065292,32.885063 258.390625,21.436710 308.871613,15.252386 C317.292297,14.220791 325.753601,13.356311 334.222168,12.919684 C346.325989,12.295630 355.267456,21.589764 353.666168,34.529034 C349.868744,65.213501 348.537781,96.246513 341.092957,126.439857 C326.677368,184.904236 298.682648,235.976761 256.968506,279.307343 C213.326065,324.640991 160.987228,355.220551 99.454086,369.350647 C86.185066,372.397675 72.675255,374.468994 59.210712,376.568817 C48.252338,378.277832 37.322960,368.554504 37.025162,357.267883 C36.888950,352.105377 37.000465,346.936340 37.000454,341.770264 C37.000340,275.444489 37.000156,209.118698 37.000484,142.792908 C37.000629,113.734810 53.605553,87.790001 80.711502,77.096191 C106.802269,66.802895 133.414459,57.831257 160.183716,48.171844 z M473.292908,18.782619 C506.484009,23.757580 538.685669,31.232409 570.535767,40.198071 C606.031982,50.190132 640.835815,62.258595 674.995361,76.160843 C703.688293,87.838272 720.524536,113.183640 720.506226,144.306686 C720.465149,214.123505 720.485657,283.940338 720.495422,353.757172 C720.497620,369.678192 710.628052,378.153381 694.790466,376.099304 C645.966858,369.767212 600.276794,354.609619 558.938660,327.513123 C494.944519,285.566010 450.524231,228.198090 425.068176,156.124725 C416.448669,131.720474 411.208069,106.565437 408.906525,80.760696 C407.739166,67.672127 405.700104,54.662220 404.473694,41.577518 C403.999908,36.522667 403.805481,31.137602 405.026886,26.291929 C407.479736,16.560717 413.882874,12.037321 423.787903,12.980925 C440.178345,14.542362 456.506195,16.760437 473.292908,18.782619 z M578.363403,464.347748 C615.531311,446.366882 654.581116,435.908630 694.772705,429.284882 C697.708374,428.801056 700.752136,428.857452 703.745483,428.859894 C714.899902,428.868988 720.458801,434.582397 718.691895,445.492645 C716.009277,462.057404 713.484558,478.739502 709.260254,494.946198 C694.406738,551.932251 666.318115,602.423340 631.191589,649.183105 C575.914368,722.766968 508.061218,783.052307 432.927094,835.552307 C431.018921,836.885681 429.046265,838.127625 427.153870,839.482422 C422.472626,842.833557 417.533234,844.040344 412.248444,841.064758 C406.852631,838.026794 404.090637,833.218079 404.209808,827.096191 C404.527344,810.783264 404.724487,794.456909 405.573883,778.167480 C407.684601,737.688477 413.126068,697.680664 423.966522,658.536804 C436.856445,611.992310 456.846161,569.063904 488.758606,532.264648 C513.768555,503.424927 543.806946,481.217163 578.363403,464.347748 z M100.761627,612.221680 C75.728386,572.424377 56.110188,530.629822 45.663021,484.961426 C42.630875,471.706848 40.741016,458.167358 38.833408,444.686859 C37.390755,434.492065 42.754341,427.939545 52.986912,429.076324 C69.119164,430.868469 85.245743,433.507080 101.069740,437.122314 C140.869492,446.215149 178.476105,460.947479 212.772552,483.436096 C263.321442,516.581604 297.953369,562.429321 320.055481,618.236816 C334.840118,655.568054 343.272247,694.426331 348.081482,734.171936 C351.726166,764.293335 353.368927,794.520508 353.156128,824.854004 C353.142120,826.849976 353.278076,828.905640 352.863708,830.830078 C350.998230,839.494263 340.934357,847.105835 330.938843,839.874878 C313.414551,827.197754 295.720337,814.735718 278.557312,801.583313 C248.662613,778.674316 220.522430,753.696289 193.948929,727.012268 C159.090790,692.009094 127.388931,654.396790 100.761627,612.221680 z"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,26 @@
<script lang="ts">
export let size: number = 16
export let style: string = 'fill: white;'
let clazz = ''
export { clazz as class }
</script>
<svg
version="1.2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1594 1595"
height={`${size}px`}
class={clazz}
{style}
>
<title>auth0ddd-svg</title>
<style>
.s0 {
fill: #000000;
}
</style>
<path
class="s0"
d="M160.183716,48.171844 C209.065292,32.885063 258.390625,21.436710 308.871613,15.252386 C317.292297,14.220791 325.753601,13.356311 334.222168,12.919684 C346.325989,12.295630 355.267456,21.589764 353.666168,34.529034 C349.868744,65.213501 348.537781,96.246513 341.092957,126.439857 C326.677368,184.904236 298.682648,235.976761 256.968506,279.307343 C213.326065,324.640991 160.987228,355.220551 99.454086,369.350647 C86.185066,372.397675 72.675255,374.468994 59.210712,376.568817 C48.252338,378.277832 37.322960,368.554504 37.025162,357.267883 C36.888950,352.105377 37.000465,346.936340 37.000454,341.770264 C37.000340,275.444489 37.000156,209.118698 37.000484,142.792908 C37.000629,113.734810 53.605553,87.790001 80.711502,77.096191 C106.802269,66.802895 133.414459,57.831257 160.183716,48.171844 z M473.292908,18.782619 C506.484009,23.757580 538.685669,31.232409 570.535767,40.198071 C606.031982,50.190132 640.835815,62.258595 674.995361,76.160843 C703.688293,87.838272 720.524536,113.183640 720.506226,144.306686 C720.465149,214.123505 720.485657,283.940338 720.495422,353.757172 C720.497620,369.678192 710.628052,378.153381 694.790466,376.099304 C645.966858,369.767212 600.276794,354.609619 558.938660,327.513123 C494.944519,285.566010 450.524231,228.198090 425.068176,156.124725 C416.448669,131.720474 411.208069,106.565437 408.906525,80.760696 C407.739166,67.672127 405.700104,54.662220 404.473694,41.577518 C403.999908,36.522667 403.805481,31.137602 405.026886,26.291929 C407.479736,16.560717 413.882874,12.037321 423.787903,12.980925 C440.178345,14.542362 456.506195,16.760437 473.292908,18.782619 z M578.363403,464.347748 C615.531311,446.366882 654.581116,435.908630 694.772705,429.284882 C697.708374,428.801056 700.752136,428.857452 703.745483,428.859894 C714.899902,428.868988 720.458801,434.582397 718.691895,445.492645 C716.009277,462.057404 713.484558,478.739502 709.260254,494.946198 C694.406738,551.932251 666.318115,602.423340 631.191589,649.183105 C575.914368,722.766968 508.061218,783.052307 432.927094,835.552307 C431.018921,836.885681 429.046265,838.127625 427.153870,839.482422 C422.472626,842.833557 417.533234,844.040344 412.248444,841.064758 C406.852631,838.026794 404.090637,833.218079 404.209808,827.096191 C404.527344,810.783264 404.724487,794.456909 405.573883,778.167480 C407.684601,737.688477 413.126068,697.680664 423.966522,658.536804 C436.856445,611.992310 456.846161,569.063904 488.758606,532.264648 C513.768555,503.424927 543.806946,481.217163 578.363403,464.347748 z M100.761627,612.221680 C75.728386,572.424377 56.110188,530.629822 45.663021,484.961426 C42.630875,471.706848 40.741016,458.167358 38.833408,444.686859 C37.390755,434.492065 42.754341,427.939545 52.986912,429.076324 C69.119164,430.868469 85.245743,433.507080 101.069740,437.122314 C140.869492,446.215149 178.476105,460.947479 212.772552,483.436096 C263.321442,516.581604 297.953369,562.429321 320.055481,618.236816 C334.840118,655.568054 343.272247,694.426331 348.081482,734.171936 C351.726166,764.293335 353.368927,794.520508 353.156128,824.854004 C353.142120,826.849976 353.278076,828.905640 352.863708,830.830078 C350.998230,839.494263 340.934357,847.105835 330.938843,839.874878 C313.414551,827.197754 295.720337,814.735718 278.557312,801.583313 C248.662613,778.674316 220.522430,753.696289 193.948929,727.012268 C159.090790,692.009094 127.388931,654.396790 100.761627,612.221680 z"
/>
</svg>
@@ -68,6 +68,7 @@ import GraphqlIcon from './GraphqlIcon.svelte'
import NocoDbIcon from './NocoDbIcon.svelte'
import AzureIcon from './AzureIcon.svelte'
import OktaIcon from './OktaIcon.svelte'
import Auth0Icon from './Auth0Icon.svelte'
import MsSqlServerIcon from './MSSqlServerIcon.svelte'
import AuthentikIcon from './AuthentikIcon.svelte'
import AutheliaIcon from './AutheliaIcon.svelte'
@@ -170,6 +171,7 @@ export const APP_TO_ICON_COMPONENT = {
nocodb: NocoDbIcon,
azure: AzureIcon,
okta: OktaIcon,
auth0: Auth0Icon,
authentik: AuthentikIcon,
authelia: AutheliaIcon,
kanidm: KanidmIcon,
@@ -268,6 +270,7 @@ export {
AzureIcon,
MicrosoftIcon,
OktaIcon,
Auth0Icon,
AuthentikIcon,
AutheliaIcon,
KanidmIcon,
+30 -29
View File
@@ -8,11 +8,11 @@ export interface Setting {
key: string
// If value is not specified for first element, it will automatcally use undefined
select_items?: {
label: string,
tooltip?: string,
label: string
tooltip?: string
// If not specified, label will be used
value?: any,
}[],
value?: any
}[]
fieldType:
| 'text'
| 'number'
@@ -41,7 +41,7 @@ export interface Setting {
isValid?: (value: any) => boolean
error?: string
defaultValue?: () => any
codeAreaLang?: string,
codeAreaLang?: string
}
export type SettingStorage = 'setting'
@@ -81,9 +81,9 @@ export const settings: Record<string, Setting[]> = {
isValid: (value: string | undefined) =>
value
? value?.startsWith('http') &&
value.includes('://') &&
!value?.endsWith('/') &&
!value?.endsWith(' ')
value.includes('://') &&
!value?.endsWith('/') &&
!value?.endsWith(' ')
: false
},
{
@@ -182,7 +182,7 @@ export const settings: Record<string, Setting[]> = {
{
label: 'Azure OpenAI base path',
description:
'All Windmill AI features will run on the specified deployed model. Format: https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}. <a href="https://www.windmill.dev/docs/core_concepts/ai_generation#azure-openai-advanced-models">Learn more</a>',
'All workspaces using an OpenAI resource for Windmill AI will run on the specified deployed model. Format: https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}. <a href="https://www.windmill.dev/docs/core_concepts/ai_generation#azure-openai-advanced-models">Learn more</a>',
key: 'openai_azure_base_path',
fieldType: 'text',
storage: 'setting',
@@ -236,24 +236,26 @@ export const settings: Record<string, Setting[]> = {
// 2. Change LATEST_STABLE_PY in dockerfile
// 3. Change #[default] annotation for PyVersion in backend
placeholder: '3.10,3.11,3.12,3.13',
select_items: [{
label: "Latest Stable",
value: "default",
tooltip: "python-3.11",
},
{
label: "3.10",
},
{
label: "3.11",
},
{
label: "3.12",
},
{
label: "3.13",
}],
storage: 'setting',
select_items: [
{
label: 'Latest Stable',
value: 'default',
tooltip: 'python-3.11'
},
{
label: '3.10'
},
{
label: '3.11'
},
{
label: '3.12'
},
{
label: '3.13'
}
],
storage: 'setting'
},
{
label: 'Pip index url',
@@ -294,8 +296,7 @@ export const settings: Record<string, Setting[]> = {
},
{
label: 'Nuget Config',
description:
'Write a nuget.config file to set custom package sources and credentials',
description: 'Write a nuget.config file to set custom package sources and credentials',
key: 'nuget_config',
fieldType: 'codearea',
codeAreaLang: 'xml',
@@ -2,6 +2,7 @@
import Portal from '$lib/components/Portal.svelte'
import { melt, createSync } from '@melt-ui/svelte'
import type { Placement } from '@floating-ui/core'
import { pointerDownOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
@@ -12,6 +13,7 @@
export let disabled = false
export let createMenu: (any) => any
export let invisible: boolean = false
export let usePointerDownOutside: boolean = false
// Use the passed createMenu function
const menu = createMenu({
@@ -35,17 +37,36 @@
export function close() {
open = false
}
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[]
}
</script>
<div class={twMerge('w-full h-8', $$props.class)}>
<ResolveOpen {open} on:open on:close />
<button class={twMerge('w-full h-full', justifyEnd ? 'flex justify-end' : '')} {disabled}>
<button
class={twMerge('w-full h-full', justifyEnd ? 'flex justify-end' : '')}
{disabled}
use:pointerDownOutside={{
capture: true,
stopPropagation: false,
exclude: getMenuElements,
customEventName: 'pointerdown_menu'
}}
on:pointerdown_outside={() => {
if (usePointerDownOutside) {
close()
}
}}
data-menu
>
<slot name="trigger" trigger={$trigger} />
</button>
<Portal name="menu-v3">
<div class="z-[6000]" use:melt={$menuElement}>
<div class="z-[6000]" use:melt={$menuElement} data-menu>
<div
class={twMerge(
'border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto',
@@ -1,21 +1,26 @@
<script lang="ts">
import { melt } from '@melt-ui/svelte'
import { createEventDispatcher } from 'svelte'
export let href: string | undefined = undefined
export let disabled: boolean = false
export let target: string | undefined = undefined
export let item: any
const dispatch = createEventDispatcher()
</script>
{#if href}
<a {href} use:melt={item} {disabled} class={$$props.class}>
<a
use:melt={item}
{href}
class={$$props.class}
role="menuitem"
aria-disabled={disabled}
tabindex={disabled ? -1 : undefined}
{target}
>
<slot />
</a>
{:else}
<button use:melt={item} on:click={(e) => dispatch('click', e)} {disabled} class={$$props.class}>
<button use:melt={item} on:click {disabled} class={$$props.class} role="menuitem">
<slot />
</button>
{/if}
@@ -1,3 +1,4 @@
export { default as Menu } from './Menu.svelte'
export { default as Menubar } from './Menubar.svelte'
export { default as MenuSingleItem } from './MenuSingleItem.svelte'
export { default as MenuItem } from './MenuItem.svelte'
@@ -93,7 +93,7 @@
size="xs2"
variant="border"
on:click={collapse}
wrapperClasses="inline-flex w-fit"
wrapperClasses="!inline-flex w-fit"
btnClasses="font-mono h-4 text-2xs px-1 font-thin text-primary rounded-[0.275rem]"
>-</Button
>
@@ -218,7 +218,7 @@
size="xs2"
variant="border"
on:click={collapse}
wrapperClasses="inline-flex w-fit"
wrapperClasses="!inline-flex w-fit"
btnClasses="h-4 text-[9px] px-1 text-primary rounded-[0.275rem]"
>
{openBracket}{collapsedSymbol}{closeBracket}
@@ -9,15 +9,14 @@
// import { copyToClipboard } from '$lib/utils'
// import { deepEqual } from 'fast-equals'
export let flowStatus: any
export let preprocessed: boolean | undefined
// $: args =
// '_metadata' in flowStatus && 'original_args' in flowStatus['_metadata']
// ? flowStatus['_metadata']['original_args']
// : undefined
$: hasPreprocessedArgs =
'_metadata' in flowStatus && !!flowStatus['_metadata']['preprocessed_args']
$: hasPreprocessedArgs = preprocessed === true
// $: argsStr = args !== undefined ? JSON.stringify(args, null, 4) : undefined
@@ -104,6 +104,7 @@
</AddPropertyV2>
{/if}
<EditableSchemaForm
onlyMaskPassword
bind:this={editableSchemaForm}
bind:schema
on:change={() => dispatch('change', schema)}
@@ -304,7 +304,7 @@
{/if}
{:else if type === 'object' && format !== 'resource-s3_object'}
<Tabs
selected={initialObjectSelected}
bind:selected={initialObjectSelected}
on:selected={(e) => {
if (e.detail === 'custom-object') {
format = ''
@@ -327,7 +327,7 @@
</Tabs>
{/if}
{#if !(type === 'object' && oneOf && oneOf.length >= 2)}
{#if !(type === 'object' && oneOf && oneOf.length >= 2) && !(type == 'object' && initialObjectSelected == 'custom-object')}
<Label label="Default">
<ArgInput
noDefaultOnSelectFirst
@@ -7,7 +7,7 @@
export let inputSelected: 'history' | 'captures' | 'saved' | undefined = undefined
</script>
<div class="min-h-[34px]">
<div class="min-h-[38px]">
<div
class="rounded-md flex flex-row gap-2 items-center py-1 px-2 w-fit {classes['info']
.bgClass} {inputSelected ? '' : 'hidden'}"
@@ -160,7 +160,11 @@
</Label>
{#if type == 'array'}
<ArrayTypeNarrowing bind:itemsType canEditResourceType={isFlowInput || isAppInput} />
<ArrayTypeNarrowing
originalType={extra['originalType']}
bind:itemsType
canEditResourceType={isFlowInput || isAppInput}
/>
{:else if type == 'string' || ['number', 'integer', 'object'].includes(type ?? '')}
<div>
<Label label="Field settings">
@@ -1,9 +1,8 @@
<script lang="ts">
import { Code2, LayoutDashboard, Star } from 'lucide-svelte'
import { CodeXml, LayoutDashboard, Star } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { melt } from '@melt-ui/svelte'
import Menu from '$lib/components/meltComponents/Menu.svelte'
import MenuButtonMelt from '$lib/components/sidebar/MenuButtonMelt.svelte'
import { Menu, MenuItem } from '$lib/components/meltComponents'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
export let lightMode: boolean = false
export let isCollapsed: boolean = false
@@ -15,9 +14,9 @@
export let createMenu: any
</script>
<Menu {createMenu} let:item>
<Menu {createMenu} let:item usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<MenuButtonMelt
<MenuButton
class="!text-xs"
icon={Star}
label={'Favorites'}
@@ -37,14 +36,14 @@
{:else}
<div class="py-1 w-full max-w-full">
{#each favoriteLinks ?? [] as favorite (favorite.href)}
<a
<MenuItem
href={favorite.href}
use:melt={item}
class="w-full inline-flex flex-row px-4 py-2 hover:bg-surface-hover data-[highlighted]:bg-surface-hover"
{item}
class="w-full inline-flex flex-row px-4 py-2 data-[highlighted]:bg-surface-hover"
>
<span class="center-center">
{#if favorite.kind == 'script'}
<Code2 size={16} />
<CodeXml size={16} />
{:else if favorite.kind == 'flow'}
<BarsStaggered size={16} />
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
@@ -54,7 +53,7 @@
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
{favorite.label}
</span>
</a>
</MenuItem>
{/each}
</div>
{/if}
@@ -3,6 +3,8 @@
import Popover from '../Popover.svelte'
import { createEventDispatcher } from 'svelte'
import SideBarNotification from './SideBarNotification.svelte'
import { goto } from '$app/navigation'
import { conditionalMelt } from '$lib/utils'
export let label: string | undefined = undefined
export let icon: any | undefined = undefined
export let isCollapsed: boolean
@@ -12,6 +14,9 @@
export let shortcut: string = ''
export let notificationsCount: number = 0
export let color: string | null = null
export let trigger: any | undefined = undefined
export let href: string | undefined = undefined
let dispatch = createEventDispatcher()
</script>
@@ -26,19 +31,24 @@
<button
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 hover:bg-surface-hover '
: 'hover:bg-[#2A3648] text-primary-inverse dark:text-primary',
? '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',
'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
@@ -1,100 +0,0 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import Popover from '../Popover.svelte'
import { melt } from '@melt-ui/svelte'
import { createEventDispatcher } from 'svelte'
import SideBarNotification from './SideBarNotification.svelte'
import { goto } from '$app/navigation'
export let label: string | undefined = undefined
export let icon: any | undefined = undefined
export let isCollapsed: boolean
export let disabled: boolean = false
export let lightMode: boolean = false
export let stopPropagationOnClick: boolean = false
export let shortcut: string = ''
export let notificationsCount: number = 0
export let color: string | null = null
export let trigger: any | undefined = undefined
export let href: string | undefined = undefined
export let openHref: boolean = false
let dispatch = createEventDispatcher()
</script>
{#if !disabled}
<Popover
appearTimeout={0}
disappearTimeout={0}
class="w-full"
disablePopup={!isCollapsed}
placement="right"
>
<button
on:click={(e) => {
if (stopPropagationOnClick) e.preventDefault()
if (openHref && 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:melt={trigger}
title={isCollapsed ? undefined : label}
>
{#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}
{#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>
{/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>
<svelte:fragment slot="text">
{#if label}
{label}
{/if}
</svelte:fragment>
</Popover>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { classNames, conditionalMelt } from '$lib/utils'
import { navigating, page } from '$app/stores'
import Popover from '../Popover.svelte'
import { base } from '$app/paths'
@@ -10,6 +10,7 @@
export let isCollapsed: boolean
export let disabled: boolean = false
export let lightMode: boolean = false
export let item: any | undefined = undefined
let isSelected = false
@@ -23,30 +24,25 @@
</script>
{#if !disabled}
<Popover
appearTimeout={0}
disappearTimeout={0}
class="w-full"
disablePopup={!isCollapsed}
placement="right"
>
<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'
: 'bg-frost-700 hover:bg-[#30404e]'
? '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'
: 'hover:bg-[#2A3648]',
? '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}
>
{#if icon}
<svelte:component
@@ -1,86 +0,0 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { navigating, page } from '$app/stores'
import Popover from '../Popover.svelte'
import { base } from '$app/paths'
import { melt } from '@melt-ui/svelte'
export let label: string
export let href: string
export let icon: any | undefined = undefined
export let isCollapsed: boolean
export let disabled: boolean = false
export let lightMode: boolean = false
export let item: any
let isSelected = false
navigating.subscribe(() => {
if (href === `${base}/`) {
isSelected = $page.url.pathname === href
} else {
isSelected = $page.url.pathname.startsWith(href)
}
})
</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]',
'hover:transition-all',
$$props.class
)}
target={href.includes('http') ? '_blank' : null}
title={isCollapsed ? undefined : label}
use:melt={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}
@@ -22,10 +22,9 @@
import { logout } from '$lib/logout'
import DarkModeObserver from '../DarkModeObserver.svelte'
import BarsStaggered from '../icons/BarsStaggered.svelte'
import Menu from '$lib/components/meltComponents/Menu.svelte'
import Menubar from '$lib/components/meltComponents/Menubar.svelte'
import MenuButtonMelt from './MenuButtonMelt.svelte'
import MenuLinkMelt from './MenuLinkMelt.svelte'
import { Menu, Menubar } from '$lib/components/meltComponents'
import MenuButton from './MenuButton.svelte'
import MenuLink from './MenuLink.svelte'
import { melt } from '@melt-ui/svelte'
let darkMode: boolean = false
@@ -116,9 +115,9 @@
</script>
<Menubar let:createMenu>
<Menu {createMenu} let:item>
<Menu {createMenu} let:item usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<MenuButtonMelt
<MenuButton
class="!text-xs"
icon={MenuIcon}
{isCollapsed}
@@ -154,7 +153,7 @@
</div>
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLinkMelt class="!text-xs" {...menuLink} {isCollapsed} lightMode {item} />
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} lightMode {item} />
{/each}
<div class="divide-y" role="none">
@@ -283,7 +282,13 @@
</div>
</div>
{#if $enterpriseLicense}
<MultiplayerMenu />
<div
on:mouseenter={() => (moreOpen = true)}
on:mouseleave={() => (moreOpen = false)}
role="none"
>
<MultiplayerMenu />
</div>
{/if}
</Menu>
</Menubar>
@@ -50,12 +50,9 @@
import SideBarNotification from './SideBarNotification.svelte'
import KafkaIcon from '../icons/KafkaIcon.svelte'
import NatsIcon from '../icons/NatsIcon.svelte'
import Menubar from '../meltComponents/Menubar.svelte'
import Menu from '../meltComponents/Menu.svelte'
import { Menubar, Menu, MenuSingleItem, MenuItem } from '$lib/components/meltComponents'
import { melt } from '@melt-ui/svelte'
import MenuButtonMelt from './MenuButtonMelt.svelte'
import MenuSingleItem from '../meltComponents/MenuSingleItem.svelte'
import MenuLinkMelt from './MenuLinkMelt.svelte'
import MenuButton from './MenuButton.svelte'
export let numUnacknowledgedCriticalAlerts = 0
$: mainMenuLinks = [
@@ -88,7 +85,7 @@
goto('/user/workspaces')
}
const defaultExtraTriggerLinks = [
$: defaultExtraTriggerLinks = [
{
label: 'HTTP',
href: '/routes',
@@ -292,7 +289,6 @@
]
export let isCollapsed: boolean = false
export let noGap: boolean = false
let leaveWorkspaceModal = false
@@ -303,15 +299,18 @@
}
return count
}
const itemClass = twMerge(
'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
</script>
<nav
class={twMerge(
'grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 justify-between',
noGap ? 'gap-0' : 'gap-16'
'grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 justify-between gap-2'
)}
>
<div class={twMerge('pt-4 ', noGap ? 'md:mb-0 mb-0' : 'mb-6 md:mb-10')}>
<div class={twMerge('pt-4 mb-6 md:mb-10')}>
<div class="space-y-1">
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
@@ -322,220 +321,148 @@
class="text-gray-400 text-[0.5rem] uppercase transition-opacity"
class:opacity-0={isCollapsed}>Triggers</div
>
<div class="space-y-1">
<Menubar let:createMenu>
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
<Menu {createMenu} let:item>
<svelte:fragment slot="trigger" let:trigger>
<div
slot="trigger"
class={twMerge(
'w-full text-gray-400 text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-[#2A3648] dark:hover:bg-[#30404e] rounded',
'data-[highlighted]:bg-[#2A3648] dark:data-[highlighted]:bg-[#30404e]'
)}
use:melt={trigger}
>
<Plus size={14} />
<Menubar let:createMenu class="flex flex-col gap-1">
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
<Menu {createMenu} let:item usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<div
slot="trigger"
class={twMerge(
'w-full text-gray-400 text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-[#2A3648] dark:hover:bg-[#30404e] rounded',
'data-[highlighted]:bg-[#2A3648] dark:data-[highlighted]:bg-[#30404e]'
)}
use:melt={trigger}
>
<Plus size={14} />
</div>
</svelte:fragment>
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.disabled ? '' : subItem.href}
class={twMerge(itemClass, subItem.disabled ? 'pointer-events-none opacity-50' : '')}
{item}
disabled={subItem.disabled}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<svelte:component this={subItem.icon} size={16} />
{/if}
{subItem.label}
</div>
</svelte:fragment>
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
<div>
<div class="py-1" role="none">
<a
href={subItem.disabled ? '' : subItem.href}
class={twMerge(
'text-secondary block px-4 py-2 text-2xs hover:bg-surface-hover hover:text-primary',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary',
subItem.disabled ? 'pointer-events-none opacity-50' : ''
)}
role="menuitem"
tabindex="-1"
use:melt={item}
data-disabled={subItem.disabled ? true : undefined}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<svelte:component this={subItem.icon} size={16} />
{/if}
{subItem.label}
</div>
</a>
</div>
</div>
{/each}
</Menu>
{/if}
</Menubar>
</div>
</MenuItem>
{/each}
</Menu>
{/if}
</Menubar>
</div>
</div>
<div class="flex flex-col h-full justify-end">
<div class={twMerge('space-y-0.5 mb-6 md:mb-10', noGap ? 'md:mb-0 mb-0' : 'mb-6 md:mb-10')}>
<Menubar let:createMenu>
<UserMenu {isCollapsed} {createMenu} />
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} let:item>
<svelte:fragment slot="trigger" let:trigger>
<MenuButtonMelt
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
</svelte:fragment>
<div>
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<div class="py-1" role="none">
{#if subItem?.['action']}
<button
class="text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary"
on:click={subItem?.['action']}
use:melt={item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<svelte:component this={subItem.icon} size={16} />
{/if}
<Menubar let:createMenu class="flex flex-col gap-1 mb-6 md:mb-10">
<UserMenu {isCollapsed} {createMenu} />
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification
notificationCount={subItem['notificationCount']}
/>
</div>
{/if}
</div>
</button>
{:else}
<a
href={subItem.href}
class="text-secondary font-normal block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary"
role="menuitem"
tabindex="-1"
use:melt={item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<svelte:component this={subItem.icon} size={16} />
{/if}
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} let:item usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
</svelte:fragment>
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification
notificationCount={subItem['notificationCount']}
/>
</div>
{/if}
</div>
</a>
{/if}
</div>
{/each}
</div>
</Menu>
{:else}
<div>
<MenuSingleItem {createMenu} let:item>
<svelte:fragment slot="trigger" let:trigger>
<div class="w-full">
<MenuButtonMelt
class="!text-2xs"
{...menuLink}
{isCollapsed}
{trigger}
openHref={true}
/>
</div>
</svelte:fragment>
<MenuLinkMelt class="!text-2xs" {...menuLink} {isCollapsed} {item} />
</MenuSingleItem>
</div>
{/if}
{/each}
</Menubar>
</div>
<div class="space-y-0.5">
<Menubar let:createMenu>
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} let:item>
<svelte:fragment slot="trigger" let:trigger>
<button
class="relative w-full"
on:click={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButtonMelt class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span class="absolute top-1 right-1 flex h-2 w-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
/>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500" />
</span>
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
on:click={() => {
subItem?.['action']?.()
}}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<svelte:component this={subItem.icon} size={16} />
{/if}
</button>
</svelte:fragment>
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<div class="py-1" role="none">
<a
href={subItem.href}
class={twMerge(
'text-secondary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary relative',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
role="menuitem"
tabindex="-1"
target="_blank"
use:melt={item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<svelte:component this={subItem.icon} size={16} />
{/if}
{subItem.label}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification notificationCount={subItem['notificationCount']} />
</div>
</a>
{/if}
</div>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t" />
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<div class="py-1" role="none">
<a
href={changelog.href}
class="text-secondary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary relative"
role="menuitem"
tabindex="-1"
target="_blank"
use:melt={item}
>
<div class="flex flex-row items-center gap-2">
{changelog.label}
</div>
</a>
</MenuItem>
{/each}
</Menu>
{:else}
<MenuSingleItem {createMenu} let:item>
<svelte:fragment slot="trigger" let:trigger>
<div class="w-full">
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
</div>
</svelte:fragment>
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} {item} />
</MenuSingleItem>
{/if}
{/each}
</Menubar>
<Menubar let:createMenu class="flex flex-col gap-1">
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} let:item usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<button
class="relative w-full"
on:click={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span class="absolute top-1 right-1 flex h-2 w-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
/>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500" />
</span>
{/if}
</button>
</svelte:fragment>
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem href={subItem.href} class={itemClass} target="_blank" {item}>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<svelte:component this={subItem.icon} size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t" />
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
<div class="flex flex-row items-center gap-2">
{changelog.label}
</div>
{/each}
{/if}
</Menu>
{/if}
{/each}
</Menubar>
</div>
</MenuItem>
{/each}
{/if}
</Menu>
{/if}
{/each}
</Menubar>
</div>
</nav>
@@ -13,19 +13,25 @@
import { twMerge } from 'tailwind-merge'
import { Crown, HardHat, LogOut, Moon, Settings, Sun, User } from 'lucide-svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import MenuButtonMelt from './MenuButtonMelt.svelte'
import MenuButton from './MenuButton.svelte'
import { Menu, MenuItem } from '$lib/components/meltComponents'
import { melt } from '@melt-ui/svelte'
import Menu from '$lib/components/meltComponents/Menu.svelte'
let darkMode: boolean = false
export let isCollapsed: boolean = false
export let lightMode: boolean = false
export let createMenu: (any) => any
const itemClass = twMerge(
'text-secondary text-left font-normal text-xs ',
'flex flex-row items-center gap-2 px-4 py-3 w-full',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)
</script>
<Menu {createMenu} let:item>
<Menu {createMenu} let:item usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<MenuButtonMelt
<MenuButton
class="!text-xs"
icon={User}
label={`User (${$userStore?.username ?? $userStore?.email})`}
@@ -34,130 +40,104 @@
{trigger}
/>
</svelte:fragment>
<div class="divide-y z-20">
<div class="px-4 py-3" role="none">
<p class="text-sm font-medium text-primary truncate" role="none">
{$userStore?.email}
</p>
<span class="text-xs text-tertiary flex flex-row gap-2 items-center">
{#if $userStore?.is_admin}
Admin of this workspace <Crown size={14} />
{:else if $userStore?.operator}
Operator in this workspace <HardHat size={14} />
{/if}
</span>
</div>
<div class="py-1" role="none">
<a
href={USER_SETTINGS_HASH}
class={twMerge(
'flex flex-row gap-2 items-center px-4 py-2 ',
'text-secondary text-sm',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
use:melt={item}
>
<Settings size={14} />
Account settings
</a>
</div>
<div class="px-4 py-3 border-b" role="none">
<p class="text-sm font-medium text-primary truncate" role="none">
{$userStore?.email}
</p>
<span class="text-xs text-tertiary flex flex-row gap-2 items-center">
{#if $userStore?.is_admin}
Admin of this workspace <Crown size={14} />
{:else if $userStore?.operator}
Operator in this workspace <HardHat size={14} />
{/if}
</span>
</div>
<div class="py-1">
<MenuItem href={USER_SETTINGS_HASH} class={itemClass} {item}>
<Settings size={16} />
Account settings
</MenuItem>
<div class="py-1" role="none">
<button
on:click={() => {
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
window.localStorage.setItem('dark-mode', 'dark')
} else {
document.documentElement.classList.remove('dark')
window.localStorage.setItem('dark-mode', 'light')
}
}}
class={twMerge(
'text-secondary block text-left px-4 py-2 font-normal text-sm w-full',
'flex flex-row items-center gap-2',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
role="menuitem"
tabindex="-1"
use:melt={item}
>
{#if darkMode}
<Sun size={14} />
{:else}
<Moon size={14} />
{/if}
Switch theme
</button>
</div>
<MenuItem
on:click={() => {
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
window.localStorage.setItem('dark-mode', 'dark')
} else {
document.documentElement.classList.remove('dark')
window.localStorage.setItem('dark-mode', 'light')
}
}}
class={itemClass}
{item}
>
{#if darkMode}
<Sun size={16} />
{:else}
<Moon size={16} />
{/if}
Switch theme
</MenuItem>
<div class="py-1" role="none">
<button
on:click={() => logout()}
class={twMerge(
'flex flex-row gap-2 items-center px-4 py-2 w-full',
'text-secondary text-sm',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
use:melt={item}
>
<LogOut size={14} />
Sign out
</button>
</div>
<MenuItem
on:click={() => logout()}
class={twMerge(itemClass, 'text-primary font-semibold')}
{item}
>
<LogOut size={16} />
Sign out
</MenuItem>
</div>
{#if isCloudHosted()}
{#if isCloudHosted()}
<div class="border-t">
{#if !$isPremiumStore}
<div class="py-1" role="none">
<span class="text-secondary block w-full text-left px-4 py-2 text-sm"
>{$usageStore}/1000 user execs</span
>
<div class="w-full bg-gray-200 h-1">
<div class="bg-blue-400 h-1" style="width: {Math.min($usageStore, 1000) / 10}%" />
<span class="text-secondary block w-full text-left px-4 py-2 text-xs"
>{$usageStore}/1000 user execs</span
>
<div class="px-4 w-full h-1 mb-1">
<div class="bg-gray-200 h-full rounded-sm overflow-hidden">
<div class="bg-blue-400 h-full" style="width: {Math.min($usageStore, 1000) / 10}%" />
</div>
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs"
>{$workspaceUsageStore}/1000 free workspace execs</span
>
<div class="w-full bg-gray-200 h-1">
</div>
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs"
>{$workspaceUsageStore}/1000 free workspace execs</span
>
<div class="px-4 w-full h-1 mb-1">
<div class="bg-gray-200 h-full rounded-sm overflow-hidden">
<div
class="bg-blue-400 h-1"
class="bg-blue-400 h-full"
style="width: {Math.min($workspaceUsageStore, 1000) / 10}%"
/>
</div>
{/if}
{#if $userStore?.is_admin}
<button
type="button"
class="text-secondary block font-normal w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"
role="menuitem"
tabindex="-1"
on:click={() => {
goto('/workspace_settings?tab=premium')
}}
>
Upgrade
</button>
{/if}
</div>
{:else}
<div class="py-1" role="none">
<button
type="button"
class="text-secondary block font-normal w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"
role="menuitem"
tabindex="-1"
</div>
{/if}
{#if $userStore?.is_admin}
<MenuItem
class={twMerge(itemClass, 'py-2')}
on:click={() => {
goto('/workspace_settings?tab=premium')
}}
{item}
>
Premium plan
</button>
</div>
Upgrade
</MenuItem>
{/if}
{:else}
<MenuItem
class={twMerge(itemClass, 'py-2')}
on:click={() => {
goto('/workspace_settings?tab=premium')
}}
{item}
>
Premium plan
</MenuItem>
{/if}
{/if}
</div>
</div>
{/if}
</Menu>
<DarkModeObserver bind:darkMode />
@@ -8,8 +8,8 @@
workspaceUsageStore
} from '$lib/stores'
import { Building, Plus, Settings } from 'lucide-svelte'
import MenuButtonMelt from '$lib/components/sidebar/MenuButtonMelt.svelte'
import Menu from '$lib/components/meltComponents/Menu.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { Menu } from '$lib/components/meltComponents'
import { melt } from '@melt-ui/svelte'
import { goto } from '$lib/navigation'
import { base } from '$lib/base'
@@ -18,7 +18,7 @@
import MultiplayerMenu from './MultiplayerMenu.svelte'
import { enterpriseLicense } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import { initAllAiWorkspace } from '../copilot/lib'
import { workspaceAIClients } from '../copilot/lib'
import { twMerge } from 'tailwind-merge'
export let isCollapsed: boolean = false
@@ -31,7 +31,7 @@
if ($workspaceStore === id) {
return
}
initAllAiWorkspace(id, true)
workspaceAIClients.init(id)
const editPages = [
'/scripts/edit/',
'/flows/edit/',
@@ -54,9 +54,9 @@
}
</script>
<Menu {createMenu} let:item>
<Menu {createMenu} let:item usePointerDownOutside>
<svelte:fragment slot="trigger" let:trigger>
<MenuButtonMelt
<MenuButton
class="!text-xs"
icon={Building}
label={$workspaceStore ?? ''}
@@ -200,8 +200,6 @@
/>
{:else if captureType === 'http'}
<RouteEditorConfigSection
{isFlow}
{path}
{showCapture}
can_write={true}
runnableArgs={data?.args}
@@ -21,23 +21,26 @@
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let newItem: boolean
export let openForm: boolean = false
export let alwaysOpened: boolean = false
export let showCapture: boolean = false
const captureTypeLabels: Record<CaptureTriggerKind, string> = {
http: 'New custom HTTP route',
websocket: 'New WebSocket trigger',
http: '+ New custom HTTP route',
websocket: '+ New WebSocket trigger',
webhook: 'Webhook',
kafka: 'New Kafka trigger',
kafka: '+ New Kafka trigger',
email: 'Email trigger',
nats: 'NATS trigger'
nats: '+ New NATS trigger'
}
const { captureOn } = getContext<TriggerContext>('TriggerContext')
let args: Record<string, any> = {}
$: collapsed = !openForm
const dispatch = createEventDispatcher()
let showCapture = false
let init = false
$: updateShowCapture(!!$captureOn)
function updateShowCapture(show: boolean) {
@@ -49,55 +52,57 @@
}
</script>
<Section label={captureTypeLabels[triggerType]}>
<Section label={captureTypeLabels[triggerType]} collapsable={!alwaysOpened} bind:collapsed>
<svelte:fragment slot="action">
<div class="flex flex-row grow w-min-0 gap-2 items-center justify-end">
{#if isEditor}
<Button
size="xs2"
on:click={() => {
showCapture = !showCapture
}}
variant="border"
color="light"
endIcon={{
icon: ChevronDown,
classes: twMerge('transition', showCapture ? 'rotate-180' : '')
}}
>
Test trigger
</Button>
{/if}
{#if !noSave}
{@const disabled = newItem || cloudDisabled}
<Popover notClickable>
{#if !collapsed || alwaysOpened}
<div class="flex flex-row grow w-min-0 gap-2 items-center justify-end">
{#if isEditor}
<Button
size="xs2"
{disabled}
startIcon={{ icon: Save }}
on:click={() => {
dispatch('saveTrigger', {
config: args
})
showCapture = !showCapture
}}
variant="border"
color="light"
endIcon={{
icon: ChevronDown,
classes: twMerge('transition', showCapture ? 'rotate-180' : '')
}}
>
Save
Test trigger
</Button>
<svelte:fragment slot="text">
{#if disabled}
{#if newItem}
Deploy the runnable to enable trigger creation
{:else if cloudDisabled}
{capitalize(triggerType)} triggers are disabled in the multi-tenant cloud
{/if}
{#if !noSave}
{@const disabled = newItem || cloudDisabled}
<Popover notClickable>
<Button
size="xs2"
{disabled}
startIcon={{ icon: Save }}
on:click={() => {
dispatch('saveTrigger', {
config: args
})
}}
>
Save
</Button>
<svelte:fragment slot="text">
{#if disabled}
{#if newItem}
Deploy the runnable to enable trigger creation
{:else if cloudDisabled}
{capitalize(triggerType)} triggers are disabled in the multi-tenant cloud
{/if}
{:else}
Create new {captureTypeLabels[triggerType].toLowerCase()}
{/if}
{:else}
Create new {captureTypeLabels[triggerType].toLowerCase()}
{/if}
</svelte:fragment>
</Popover>
{/if}
</div>
</svelte:fragment>
</Popover>
{/if}
</div>
{/if}
</svelte:fragment>
{#if isEditor}
@@ -47,8 +47,6 @@
bind:route_path={args.route_path}
bind:http_method={args.http_method}
headless
{isFlow}
{path}
/>
{:else if triggerType === 'email'}
<EmailTriggerConfigSection
@@ -10,15 +10,14 @@
import { bash } from 'svelte-highlight/languages'
import { HttpTriggerService } from '$lib/gen'
// import { page } from '$app/stores'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import ClipboardPanel from '../../details/ClipboardPanel.svelte'
import { isCloudHosted } from '$lib/cloud'
export let isFlow: boolean
export let path: string
export let initialTriggerPath: string | undefined = undefined
export let dirtyRoutePath: boolean = false
export let route_path: string | undefined
export let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' | undefined
@@ -26,7 +25,6 @@
export let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined =
undefined
export let showCapture = false
export let initialRoutePath: string = ''
export let headless: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
@@ -36,16 +34,16 @@
let routeError: string = ''
async function validateRoute(
path: string | undefined,
routePath: string | undefined,
method: typeof http_method
): Promise<void> {
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!path || !method || !/^[\w-:]+(\/[\w-:]+)*$/.test(path)) {
if (!routePath || !method || !/^:?[-\w]+(\/:?[-\w]+)*$/.test(routePath)) {
routeError = 'Endpoint not valid'
} else if (initialRoutePath !== path && (await routeExists(path, method))) {
} else if (await routeExists(routePath, method)) {
routeError = 'Endpoint already taken'
} else {
routeError = ''
@@ -59,14 +57,15 @@
workspace: $workspaceStore!,
requestBody: {
route_path,
http_method: method
http_method: method,
trigger_path: initialTriggerPath
}
})
}
$: captureURL = `${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${
isFlow ? 'flow' : 'script'
}/${path.replaceAll('/', '.')}/${route_path}`
$: validateRoute(route_path, http_method)
$: isValid = routeError === ''
function getHttpRoute(route_path: string | undefined) {
return `${location.origin}${base}/api/r/${isCloudHosted() ? $workspaceStore + '/' : ''}${
@@ -74,10 +73,6 @@
}`
}
$: validateRoute(route_path, http_method)
$: isValid = routeError === ''
$: fullRoute = getHttpRoute(route_path)
$: !http_method && (http_method = 'post')
@@ -86,6 +81,9 @@
<div>
{#if showCapture && captureInfo}
{@const captureURL = `${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${
captureInfo.isFlow ? 'flow' : 'script'
}/${captureInfo.path.replaceAll('/', '.')}/${route_path}`}
<CaptureSection
captureType="http"
disabled={!isValid}
@@ -22,7 +22,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import RouteEditorConfigSection from './RouteEditorConfigSection.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { isCloudHosted } from '$lib/cloud'
let is_flow: boolean = false
let initialPath = ''
let edit = true
@@ -66,7 +66,6 @@
itemKind = nis_flow ? 'flow' : 'script'
is_async = false
requires_auth = false
initialRoutePath = ''
route_path = defaultValues?.route_path ?? ''
dirtyRoutePath = false
http_method = defaultValues?.http_method ?? 'post'
@@ -78,6 +77,7 @@
path = ''
initialPath = ''
dirtyPath = false
is_static_website = false
} finally {
drawerLoading = false
}
@@ -89,11 +89,11 @@
let dirtyRoutePath = false
let is_async = false
let requires_auth = false
let initialRoutePath = ''
let route_path = ''
let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'post'
let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined =
undefined
let is_static_website = false
let s3FilePicker: S3FilePicker
let s3FileUploadRawMode = false
@@ -113,12 +113,14 @@
is_flow = s.is_flow
path = s.path
route_path = s.route_path
initialRoutePath = s.route_path
http_method = s.http_method ?? 'post'
is_async = s.is_async
requires_auth = s.requires_auth
static_asset_config = s.static_asset_config
s3FileUploadRawMode = !!static_asset_config
if (!isCloudHosted()) {
static_asset_config = s.static_asset_config
s3FileUploadRawMode = !!static_asset_config
is_static_website = s.is_static_website
}
can_write = canWrite(s.path, s.extra_perms, $userStore)
}
@@ -136,7 +138,8 @@
requires_auth,
route_path: $userStore?.is_admin || $userStore?.is_super_admin ? route_path : undefined,
http_method,
static_asset_config
static_asset_config,
is_static_website
}
})
sendUserToast(`Route ${path} updated`)
@@ -151,7 +154,8 @@
requires_auth,
route_path,
http_method,
static_asset_config
static_asset_config,
is_static_website
}
})
sendUserToast(`Route ${path} created`)
@@ -166,11 +170,14 @@
let drawer: Drawer
let dirtyPath = false
$: !static_asset_config && (is_static_website = false)
</script>
{#if static_asset_config}
<S3FilePicker
bind:this={s3FilePicker}
folderOnly={is_static_website}
bind:selectedFileKey={static_asset_config}
on:close={() => {
s3Editor?.setCode(JSON.stringify(static_asset_config, null, 2))
@@ -220,102 +227,125 @@
</div>
<RouteEditorConfigSection
isFlow={is_flow}
{path}
initialTriggerPath={initialPath}
bind:route_path
bind:isValid
bind:dirtyRoutePath
bind:http_method
{can_write}
bind:static_asset_config
{initialRoutePath}
/>
<Section label="Target">
<ToggleButtonGroup
disabled={fixedScriptPath != '' || !can_write}
selected={static_asset_config ? 'static_asset' : 'runnable'}
on:selected={(ev) => {
if (ev.detail === 'static_asset') {
static_asset_config = { s3: '' }
script_path = ''
initialScriptPath = ''
is_flow = false
http_method = 'get'
} else {
static_asset_config = undefined
}
}}
>
<ToggleButton label="Runnable" value="runnable" />
<ToggleButton label="Static asset" value="static_asset" />
</ToggleButtonGroup>
{#if !isCloudHosted()}
<ToggleButtonGroup
disabled={fixedScriptPath != '' || !can_write}
selected={static_asset_config
? is_static_website
? 'static_website'
: 'static_asset'
: 'runnable'}
on:selected={(ev) => {
if (ev.detail === 'static_asset' || ev.detail === 'static_website') {
static_asset_config = { s3: '' }
s3Editor?.setCode(JSON.stringify(static_asset_config, null, 2))
script_path = ''
initialScriptPath = ''
is_flow = false
http_method = 'get'
is_async = false
is_static_website = ev.detail === 'static_website'
if (is_static_website) {
requires_auth = false
}
} else if (ev.detail === 'runnable') {
static_asset_config = undefined
}
}}
>
<ToggleButton label="Runnable" value="runnable" />
<ToggleButton label="Static asset" value="static_asset" />
<ToggleButton label="Static website" value="static_website" />
</ToggleButtonGroup>
{/if}
{#if static_asset_config}
<div class="flex flex-col w-full gap-1">
{#if can_write}
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
options={{ left: 'Existing file' }}
disabled={!can_write}
/>
{/if}
{#if s3FileUploadRawMode}
{#if is_static_website}
<p class="text-xs my-1 text-tertiary">
Upload or specify a <b>folder</b> on S3. All its files will be served under the path
above. Use this full path as the base URL of your website to ensure relative imports
work correctly.
</p>
{/if}
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full gap-1">
{#if can_write}
<JsonEditor
bind:editor={s3Editor}
on:focus={(e) => {
dispatch('focus')
}}
on:blur={(e) => {
dispatch('blur')
}}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
bind:value={static_asset_config}
/>
{:else}
<Highlight
language={json}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
/>
{/if}
{#if can_write}
<Button
variant="border"
color="light"
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(static_asset_config)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
options={{ left: 'Existing file' }}
disabled={!can_write}
/>
{/if}
{:else}
<FileUpload
allowMultiple={false}
randomFileKey={true}
on:addition={(evt) => {
static_asset_config = {
s3: evt.detail?.path ?? '',
filename: evt.detail?.filename ?? undefined
}
s3FileUploadRawMode = true
}}
on:deletion={(evt) => {
static_asset_config = {
s3: ''
}
}}
/>
{/if}
{#if s3FileUploadRawMode}
{#if can_write}
<JsonEditor
bind:editor={s3Editor}
on:focus={(e) => {
dispatch('focus')
}}
on:blur={(e) => {
dispatch('blur')
}}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
bind:value={static_asset_config}
/>
{:else}
<Highlight
language={json}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
/>
{/if}
{#if can_write}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(!is_static_website ? static_asset_config : undefined)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
{/if}
{:else}
{#key is_static_website}
<FileUpload
folderOnly={is_static_website}
allowMultiple={false}
randomFileKey={true}
on:addition={(evt) => {
static_asset_config = {
s3: evt.detail?.path ?? '',
filename: evt.detail?.filename ?? undefined
}
s3FileUploadRawMode = true
}}
on:deletion={(evt) => {
static_asset_config = {
s3: ''
}
}}
/>
{/key}
{/if}
</div>
</div>
{:else}
<p class="text-xs mb-1 text-tertiary">
<p class="text-xs mt-0.5 mb-1 text-tertiary">
Pick a script or flow to be triggered<Required required={true} /><br />
To handle headers, query or path parameters, add a preprocessor to your runnable.
</p>
@@ -346,48 +376,52 @@
{/if}
</Section>
<Section label="Advanced">
<div class="flex flex-col gap-4">
<div class="flex flex-row justify-between">
<Label label="Request type" class="w-full">
{#if !is_static_website}
<Section label="Advanced">
<div class="flex flex-col gap-4">
{#if !static_asset_config}
<div class="flex flex-row justify-between">
<Label label="Request type" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={is_async}
disabled={!can_write || !!static_asset_config}
>
<ToggleButton
label="Async"
value={true}
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value={false}
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
{/if}
<Label label="Authentication" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={is_async}
bind:selected={requires_auth}
disabled={!can_write}
>
<ToggleButton label="None" value={false} />
<ToggleButton
label="Async"
label="Required"
value={true}
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value={false}
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
tooltip="Requires authentication with read access on the route"
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
<Label label="Authentication" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={requires_auth}
disabled={!can_write}
>
<ToggleButton label="None" value={false} />
<ToggleButton
label="Required"
value={true}
tooltip="Requires authentication with read access on the route"
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
</Section>
</Section>
{/if}
</div>
{/if}
</DrawerContent>
@@ -5,7 +5,7 @@
import { canWrite } from '$lib/utils'
import { getContext, onMount } from 'svelte'
import Section from '$lib/components/Section.svelte'
import { Alert, Skeleton } from '$lib/components/common'
import { Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersEditorSection from '../TriggersEditorSection.svelte'
@@ -19,6 +19,8 @@
export let args: Record<string, any> = {}
let routeEditor: RouteEditor
let openForm = true
let dontCloseOnLoad = false
$: path && loadTriggers()
const { triggersCount, selectedTrigger, defaultValues } =
@@ -48,6 +50,7 @@
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), http_routes_count: httpTriggers?.length }
openForm = httpTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load http routes', e)
}
@@ -61,11 +64,42 @@
bind:this={routeEditor}
/>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/http_routing">
Routes expose your scripts and flows as HTTP endpoints. Each route can be configured with a
specific HTTP method and path.
</Description>
{#if !newItem && httpTriggers && httpTriggers.length > 0}
<Section label="Routes">
{#if !$userStore?.is_admin && !$userStore?.is_super_admin}
<Alert title="Only workspace admins can create routes" type="warning" size="xs" />
{/if}
<div class="flex flex-col divide-y pt-2">
{#each httpTriggers as httpTriggers (httpTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{httpTriggers.path}</div>
<div class="col-span-2 truncate">
{httpTriggers.http_method.toUpperCase()} /{httpTriggers.route_path}
</div>
<div class="flex justify-end">
<button
on:click={() => routeEditor?.openEdit(httpTriggers.path, isFlow)}
class="px-2"
>
{#if httpTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</Section>
{/if}
<TriggersEditorSection
on:saveTrigger={(e) => {
routeEditor?.openNew(isFlow, path, e.detail.config)
@@ -74,6 +108,7 @@
on:addPreprocessor
on:updateSchema
on:testWithArgs
bind:showCapture={dontCloseOnLoad}
cloudDisabled={false}
triggerType="http"
{isFlow}
@@ -83,43 +118,6 @@
{hasPreprocessor}
{newItem}
data={{ args }}
bind:openForm
/>
{#if !newItem}
<Section label="Routes">
{#if !$userStore?.is_admin && !$userStore?.is_super_admin}
<Alert title="Only workspace admins can create routes" type="warning" size="xs" />
{/if}
{#if httpTriggers}
{#if httpTriggers.length == 0}
<div class="text-xs text-secondary text-center"> No http routes </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each httpTriggers as httpTriggers (httpTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{httpTriggers.path}</div>
<div class="col-span-2 truncate">
{httpTriggers.http_method.toUpperCase()} /{httpTriggers.route_path}
</div>
<div class="flex justify-end">
<button
on:click={() => routeEditor?.openEdit(httpTriggers.path, isFlow)}
class="px-2"
>
{#if httpTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</Section>
{/if}
</div>
</div>
@@ -6,7 +6,6 @@
import KafkaTriggerEditor from './KafkaTriggerEditor.svelte'
import { isCloudHosted } from '$lib/cloud'
import Section from '$lib/components/Section.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import Description from '$lib/components/Description.svelte'
import { Alert } from '$lib/components/common'
import type { TriggerContext } from '$lib/components/triggers'
@@ -20,6 +19,8 @@
export let hasPreprocessor: boolean = false
let kafkaTriggerEditor: KafkaTriggerEditor
let openForm = true
let dontCloseOnLoad = false
$: path && loadTriggers()
@@ -50,6 +51,7 @@
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), kafka_count: kafkaTriggers?.length }
openForm = kafkaTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load Kafka triggers', e)
}
@@ -85,6 +87,32 @@
<Description link="https://www.windmill.dev/docs/core_concepts/kafka_triggers">
Kafka triggers execute scripts and flows in response to messages published to Kafka topics.
</Description>
{#if !newItem && kafkaTriggers && kafkaTriggers.length > 0}
<Section label="Kafka triggers">
<div class="flex flex-col divide-y pt-2">
{#each kafkaTriggers as kafkaTrigger (kafkaTrigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{kafkaTrigger.path}</div>
<div class="col-span-2 truncate">
{kafkaTrigger.kafka_resource_path}
</div>
<div class="flex justify-end">
<button
on:click={() => kafkaTriggerEditor?.openEdit(kafkaTrigger.path, isFlow)}
class="px-2"
>
{#if kafkaTrigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</Section>
{/if}
<TriggersEditorSection
on:saveTrigger={(e) => {
saveTrigger(path, e.detail.config)
@@ -102,41 +130,8 @@
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
{openForm}
bind:showCapture={dontCloseOnLoad}
/>
{#if !newItem}
{#if kafkaTriggers}
<Section label="Kafka triggers">
{#if kafkaTriggers.length == 0}
<div class="text-xs text-secondary text-center"> No Kafka triggers </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each kafkaTriggers as kafkaTrigger (kafkaTrigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{kafkaTrigger.path}</div>
<div class="col-span-2 truncate">
{kafkaTrigger.kafka_resource_path}
</div>
<div class="flex justify-end">
<button
on:click={() => kafkaTriggerEditor?.openEdit(kafkaTrigger.path, isFlow)}
class="px-2"
>
{#if kafkaTrigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
{/if}
</Section>
{:else}
<Skeleton layout={[[8]]} />
{/if}
{/if}
</div>
{/if}
@@ -6,7 +6,6 @@
import NatsTriggerEditor from './NatsTriggerEditor.svelte'
import { isCloudHosted } from '$lib/cloud'
import Section from '$lib/components/Section.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Description from '$lib/components/Description.svelte'
import type { TriggerContext } from '$lib/components/triggers'
@@ -20,6 +19,8 @@
export let hasPreprocessor: boolean = false
let natsTriggerEditor: NatsTriggerEditor
let openForm = true
let dontCloseOnLoad = false
$: path && loadTriggers()
@@ -50,6 +51,7 @@
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), nats_count: natsTriggers?.length }
openForm = natsTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load nats triggers', e)
}
@@ -85,6 +87,34 @@
<Description link="https://www.windmill.dev/docs/core_concepts/nats_triggers">
NATS triggers execute scripts and flows in response to messages published to NATS subjects.
</Description>
{#if !newItem && natsTriggers && natsTriggers.length > 0}
<Section label="NATS Triggers">
<div class="flex flex-col divide-y pt-2">
{#each natsTriggers as natsTrigger (natsTrigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{natsTrigger.path}</div>
<div class="col-span-2 truncate">
{natsTrigger.nats_resource_path}
</div>
<div class="flex justify-end">
<button
on:click={() => natsTriggerEditor?.openEdit(natsTrigger.path, isFlow)}
class="px-2"
>
{#if natsTrigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</Section>
{/if}
<TriggersEditorSection
on:saveTrigger={(e) => {
saveTrigger(path, e.detail.config)
@@ -100,41 +130,8 @@
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
{openForm}
bind:showCapture={dontCloseOnLoad}
/>
{#if !newItem}
{#if natsTriggers}
<Section label="NATS Triggers">
{#if natsTriggers.length == 0}
<div class="text-xs text-secondary text-center"> No nats triggers </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each natsTriggers as natsTrigger (natsTrigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{natsTrigger.path}</div>
<div class="col-span-2 truncate">
{natsTrigger.nats_resource_path}
</div>
<div class="flex justify-end">
<button
on:click={() => natsTriggerEditor?.openEdit(natsTrigger.path, isFlow)}
class="px-2"
>
{#if natsTrigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
{/if}
</Section>
{:else}
<Skeleton layout={[[8]]} />
{/if}
{/if}
</div>
{/if}
@@ -53,5 +53,6 @@
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
alwaysOpened={true}
/>
</div>
@@ -7,7 +7,7 @@
import WebsocketTriggerEditor from './WebsocketTriggerEditor.svelte'
import { isCloudHosted } from '$lib/cloud'
import Section from '$lib/components/Section.svelte'
import { Alert, Skeleton } from '$lib/components/common'
import { Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersEditorSection from '../TriggersEditorSection.svelte'
@@ -19,6 +19,8 @@
export let hasPreprocessor: boolean = false
let wsTriggerEditor: WebsocketTriggerEditor
let openForm = true
let dontCloseOnLoad = false
$: path && loadTriggers()
@@ -49,6 +51,7 @@
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), websocket_count: wsTriggers?.length }
openForm = wsTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load WS triggers', e)
}
@@ -72,6 +75,36 @@
WebSocket triggers allow real-time bidirectional communication between your scripts/flows and
external systems. Each trigger creates a unique WebSocket endpoint.
</Description>
{#if !newItem && wsTriggers && wsTriggers.length > 0}
<Section label="WebSockets">
<div class="flex flex-col gap-4">
<div class="flex flex-col divide-y pt-2">
{#each wsTriggers as wsTriggers (wsTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{wsTriggers.path}</div>
<div class="col-span-2 truncate">
{wsTriggers.url}
</div>
<div class="flex justify-end">
<button
on:click={() => wsTriggerEditor?.openEdit(wsTriggers.path, isFlow)}
class="px-2"
>
{#if wsTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</div>
</Section>
{/if}
<TriggersEditorSection
on:applyArgs
on:saveTrigger={(e) => {
@@ -88,43 +121,7 @@
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
{openForm}
/>
{#if !newItem}
<Section label="WebSockets">
<div class="flex flex-col gap-4">
{#if wsTriggers}
{#if wsTriggers.length == 0}
<div class="text-xs text-secondary text-center"> No WebSocket triggers </div>
{:else}
<div class="flex flex-col divide-y pt-2">
{#each wsTriggers as wsTriggers (wsTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{wsTriggers.path}</div>
<div class="col-span-2 truncate">
{wsTriggers.url}
</div>
<div class="flex justify-end">
<button
on:click={() => wsTriggerEditor?.openEdit(wsTriggers.path, isFlow)}
class="px-2"
>
{#if wsTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</div>
</Section>
{/if}
</div>
{/if}
+23 -16
View File
@@ -5,23 +5,23 @@ export function argSigToJsonSchemaType(
| string
| { resource: string | null }
| {
list:
| (string | { object: { key: string; typ: any }[] })
| { str: any }
| { object: { key: string; typ: any }[] }
| null
}
list:
| (string | { object: { key: string; typ: any }[] })
| { str: any }
| { object: { key: string; typ: any }[] }
| null
}
| { dynselect: string }
| { str: string[] | null }
| { object: { key: string; typ: any }[] }
| {
oneof: [
{
label: string
properties: { key: string; typ: any }[]
}
]
},
oneof: [
{
label: string
properties: { key: string; typ: any }[]
}
]
},
oldS: SchemaProperty
): void {
const newS: SchemaProperty = { type: '' }
@@ -105,17 +105,22 @@ export function argSigToJsonSchemaType(
newS.type = 'array'
if (t.list === 'int' || t.list === 'float') {
newS.items = { type: 'number' }
newS.originalType = 'number[]'
} else if (t.list === 'bytes') {
newS.items = { type: 'string', contentEncoding: 'base64' }
} else if (t.list == 'string') {
newS.items = { type: 'string' }
} else if (t.list && typeof t.list == 'object' && 'str' in t.list) {
newS.originalType = 'bytes[]'
} else if (t.list && typeof t.list == 'object' && 'str' in t.list && t.list.str) {
newS.items = { type: 'string', enum: t.list.str }
newS.originalType = 'enum[]'
} else if (t.list == 'string' || (t.list && typeof t.list == 'object' && 'str' in t.list)) {
newS.items = { type: 'string', enum: oldS.items?.enum }
newS.originalType = 'string[]'
} else if (t.list && typeof t.list == 'object' && 'resource' in t.list && t.list.resource) {
newS.items = {
type: 'resource',
resourceType: t.list.resource as string
}
newS.originalType = 'resource[]'
} else if (
t.list &&
typeof t.list == 'object' &&
@@ -131,8 +136,10 @@ export function argSigToJsonSchemaType(
}
newS.items = { type: 'object', properties: properties }
newS.originalType = 'record[]'
} else {
newS.items = { type: 'object' }
newS.originalType = 'object[]'
}
} else {
newS.type = 'object'
+12 -7
View File
@@ -1,13 +1,14 @@
import { BROWSER } from 'esm-env'
import { derived, type Readable, writable } from 'svelte/store'
import type { IntrospectionQuery } from 'graphql'
import {
type WorkspaceDefaultScripts,
type AIProvider,
type OperatorSettings,
type TokenResponse,
type UserWorkspaceList,
type OperatorSettings
type WorkspaceDefaultScripts
} from './gen'
import type { IntrospectionQuery } from 'graphql'
import { getLocalSetting } from './utils'
export interface UserExt {
@@ -82,13 +83,14 @@ export const userWorkspaces: Readable<
}
})
export const copilotInfo = writable<{
ai_provider: string
ai_provider: AIProvider
exists_ai_resource: boolean
code_completion_enabled: boolean
code_completion_model?: string
ai_models: string[]
}>({
ai_provider: '',
ai_provider: 'openai',
exists_ai_resource: false,
code_completion_enabled: false
ai_models: []
})
export const codeCompletionLoading = writable<boolean>(false)
export const metadataCompletionEnabled = writable<boolean>(true)
@@ -103,6 +105,9 @@ export const vimMode = writable<boolean>(getLocalSetting(VIM_MODE_SETTING_NAME)
export const codeCompletionSessionEnabled = writable<boolean>(
getLocalSetting(CODE_COMPLETION_SETTING_NAME) != 'false'
)
export const copilotSessionModel = writable<string | undefined>(
getLocalSetting(CODE_COMPLETION_SETTING_NAME) ?? undefined
)
export const usedTriggerKinds = writable<string[]>([])
type SQLBaseSchema = {
+10 -2
View File
@@ -246,6 +246,8 @@ export function pointerDownOutside(
options?: ClickOutsideOptions
): { destroy(): void; update(newOptions: ClickOutsideOptions): void } {
const handlePointerDown = async (event: PointerEvent) => {
if (!event.isTrusted) return
if (options?.customEventName) {
node.dispatchEvent(
new CustomEvent<PointerEvent>(options.customEventName, {
@@ -351,14 +353,13 @@ export function removeItemAll<T>(arr: T[], value: T) {
}
export function emptyString(str: string | undefined | null): boolean {
return str === undefined || str === null || str === ''
return str === undefined || str === null || str === ''
}
export function emptyStringTrimmed(str: string | undefined | null): boolean {
return str === undefined || str === null || str === '' || str.trim().length === 0
}
export function defaultIfEmptyString(str: string | undefined, dflt: string): string {
return emptyString(str) ? dflt : str!
}
@@ -1104,3 +1105,10 @@ export function isScriptPreview(job_kind: Job['job_kind'] | undefined) {
!!job_kind && (job_kind === 'preview' || job_kind === 'flowscript' || job_kind === 'appscript')
)
}
export function conditionalMelt(node: HTMLElement, meltElement: any) {
if (meltElement) {
return meltElement.action(node)
}
return { destroy: () => {} }
}
@@ -44,14 +44,14 @@
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { ArrowLeft, Search } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { initAllAiWorkspace } from '$lib/components/copilot/lib'
import { workspaceAIClients } from '$lib/components/copilot/lib'
import { twMerge } from 'tailwind-merge'
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { setContext } from 'svelte'
import { base } from '$app/paths'
import Menubar from '$lib/components/meltComponents/Menubar.svelte'
import { Menubar } from '$lib/components/meltComponents'
OpenAPI.WITH_CREDENTIALS = true
let menuOpen = false
@@ -241,15 +241,19 @@
let devOnly = $page.url.pathname.startsWith(base + '/scripts/dev')
async function loadCopilot(workspace: string) {
initAllAiWorkspace(workspace)
workspaceAIClients.init(workspace)
try {
copilotInfo.set(await WorkspaceService.getCopilotInfo({ workspace }))
} catch (err) {
console.log(err)
const info = await WorkspaceService.getCopilotInfo({ workspace })
copilotInfo.set({
ai_provider: '',
...info,
ai_provider: info.ai_provider ?? 'openai'
})
} catch (err) {
copilotInfo.set({
ai_provider: 'openai',
exists_ai_resource: false,
code_completion_enabled: false
code_completion_model: undefined,
ai_models: []
})
console.error('Could not get copilot info')
}
@@ -380,7 +384,7 @@
>
<div
class={classNames(
'absolute top-0 right-0 -mr-12 pt-2 ease-in-out duration-300',
'absolute top-0 right-4 -mr-12 pt-2 ease-in-out duration-300',
menuOpen ? 'opacity-100' : 'opacity-0'
)}
>
@@ -389,10 +393,10 @@
on:click={() => {
menuOpen = !menuOpen
}}
class="ml-1 flex items-center justify-center h-8 w-8 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
class="ml-1 flex items-center justify-center h-6 w-6 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
>
<svg
class="h-6 w-6 text-white"
class="h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -408,12 +412,12 @@
</svg>
</button>
</div>
<div class="dark:bg-[#1e232e] bg-[#202125] h-full !dark">
<div class="dark:bg-[#1e232e] bg-[#202125] h-full !dark flex flex-col">
<div class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-gray-200 w-40">
<WindmillIcon white={true} height="20px" width="20px" />
Windmill
</div>
<div class="px-2 py-4 space-y-2 border-y border-gray-500">
<div class="px-2 py-4 border-y border-gray-500">
<Menubar let:createMenu>
<WorkspaceMenu {createMenu} />
<FavoriteMenu {createMenu} {favoriteLinks} />
@@ -468,8 +472,8 @@
{/if}
</div>
</button>
<div class="px-2 py-4 space-y-2 border-y border-gray-700">
<Menubar let:createMenu>
<div class="px-2 py-4 border-y border-gray-700 flex flex-col gap-1">
<Menubar let:createMenu class="flex flex-col gap-1">
<WorkspaceMenu {createMenu} {isCollapsed} />
<FavoriteMenu {createMenu} {favoriteLinks} {isCollapsed} />
</Menubar>
@@ -515,6 +519,7 @@
</div>
{/if}
<!-- Legacy menu -->
<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',
@@ -109,7 +109,11 @@
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
new Set(
filteredItems
?.filter((x) => x.script_path)
.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []
)
).sort()
$: items = filter !== '' ? filteredItems : preFilteredItems
@@ -156,176 +160,177 @@
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Custom HTTP routes"
tooltip="Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing"
>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Button size="md" startIcon={{ icon: Plus }} on:click={() => routeEditor.openNew(false)}>
New&nbsp;route
</Button>
{/if}
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search routes" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
<ToggleButton small value="trigger" label="Route" icon={Route} />
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} />
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#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}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<div class="text-center text-sm text-tertiary mt-2"> No routes </div>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config } (path)}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
on:click={() => routeEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-primary flex-wrap text-left text-md font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{http_method.toUpperCase()} /{route_path}
{/if}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{#if static_asset_config}
file: {static_asset_config.s3}
{:else}
runnable: {script_path}
{/if}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => routeEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
color="dark"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite || !($userStore?.is_admin || $userStore?.is_super_admin),
action: async () => {
await HttpTriggerService.deleteHttpTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
routeEditor?.openEdit(path, is_flow)
}
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
icon: Share,
action: () => {
shareModal.openDrawer(path, 'http_trigger')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-tertiary gap-1 items-center justify-end truncate pr-2"
>
<div class="truncate">edited by {edited_by}</div>
<div class="truncate">at {displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</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
{:else}
<CenteredPage>
<PageHeader
title="Custom HTTP routes"
tooltip="Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing"
>
{/if}
</CenteredPage>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Button size="md" startIcon={{ icon: Plus }} on:click={() => routeEditor.openNew(false)}>
New&nbsp;route
</Button>
{/if}
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search routes" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
<ToggleButton small value="trigger" label="Route" icon={Route} />
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} />
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#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}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<div class="text-center text-sm text-tertiary mt-2"> No routes </div>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config } (path)}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
on:click={() => routeEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-primary flex-wrap text-left text-md font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{http_method.toUpperCase()} /{route_path}
{/if}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{#if static_asset_config}
file: {static_asset_config.s3}
{:else}
runnable: {script_path}
{/if}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => routeEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
color="dark"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled:
!canWrite || !($userStore?.is_admin || $userStore?.is_super_admin),
action: async () => {
await HttpTriggerService.deleteHttpTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
routeEditor?.openEdit(path, is_flow)
}
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
icon: Share,
action: () => {
shareModal.openDrawer(path, 'http_trigger')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-tertiary gap-1 items-center justify-end truncate pr-2"
>
<div class="truncate">edited by {edited_by}</div>
<div class="truncate">at {displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</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}
</CenteredPage>
{/if}
<ShareModal
@@ -726,7 +726,7 @@
</div>
{/if}
{#if job && job.flow_status && job.job_kind === 'script'}
<PreprocessedArgsDisplay flowStatus={job.flow_status} />
<PreprocessedArgsDisplay preprocessed={job.preprocessed} />
{/if}
{#if persistentScriptDefinition}
<button on:click={() => persistentScriptDrawer.open?.(persistentScriptDefinition)}
@@ -8,7 +8,7 @@
import { generateRandomString } from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import Menubar from '$lib/components/meltComponents/Menubar.svelte'
import { Menubar } from '$lib/components/meltComponents'
let itemPath: string | undefined = undefined
let itemKind: 'script' | 'flow' | 'app' = 'script'
@@ -6,7 +6,8 @@
SettingService,
UserService,
VariableService,
WorkspaceService
WorkspaceService,
type AIProvider
} from '$lib/gen'
import { validateUsername } from '$lib/utils'
import { logoutWithRedirect } from '$lib/logout'
@@ -18,12 +19,12 @@
import Tooltip from '$lib/components/Tooltip.svelte'
import { onMount } from 'svelte'
import { sendUserToast } from '$lib/toast'
import TestAiKey from '$lib/components/copilot/TestAiKey.svelte'
import TestAIKey from '$lib/components/copilot/TestAIKey.svelte'
import { switchWorkspace } from '$lib/storeUtils'
import { isCloudHosted } from '$lib/cloud'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import type { AiProviderTypes } from '$lib/components/copilot/lib'
import { AI_DEFAULT_MODELS } from '$lib/components/copilot/lib'
const rd = $page.url.searchParams.get('rd')
@@ -41,8 +42,12 @@
let colorEnabled = false
function generateRandomColor() {
const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
workspaceColor = randomColor;
const randomColor =
'#' +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0')
workspaceColor = randomColor
}
$: id = name.toLowerCase().replace(/\s/gi, '-')
@@ -111,7 +116,8 @@
workspace: id,
requestBody: {
ai_resource: { path, provider: selected },
code_completion_enabled: codeCompletionEnabled
ai_models: aiKey ? AI_DEFAULT_MODELS[selected].slice(0, 1) : [],
code_completion_model: codeCompletionEnabled ? AI_DEFAULT_MODELS[selected][0] : undefined
}
})
}
@@ -178,7 +184,7 @@
let auto_invite = false
let operatorOnly = false
let selected: AiProviderTypes = 'openai'
let selected: Exclude<AIProvider, 'customai'> = 'openai'
</script>
<CenteredModal title="New Workspace">
@@ -198,11 +204,23 @@
</label>
<label class="block pb-4">
<span class="text-secondary text-sm">Workspace color</span>
<span class="ml-5 text-tertiary text-xs">Color to identify the current workspace in the list of workspaces</span>
<span class="ml-5 text-tertiary text-xs"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-2">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}<input class="w-10" type="color" bind:value={workspaceColor} disabled={!colorEnabled} />{/if}
<input type="text" class="w-24 text-sm" bind:value={workspaceColor} disabled={!colorEnabled} />
{#if colorEnabled}<input
class="w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>{/if}
<input
type="text"
class="w-24 text-sm"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<Button on:click={generateRandomColor} size="xs" disabled={!colorEnabled}>Random</Button>
</div>
</label>
@@ -230,9 +248,12 @@
</span>
<div class="pb-2">
<ToggleButtonGroup bind:selected>
<ToggleButton value="openai" label="OpenAi" />
<ToggleButton value="openai" label="OpenAI" />
<ToggleButton value="anthropic" label="Anthropic" />
<ToggleButton value="mistral" label="Mistral" />
<ToggleButton value="deepseek" label="DeepSeek" />
<ToggleButton value="groq" label="Groq" />
<ToggleButton value="openrouter" label="OpenRouter" />
</ToggleButtonGroup>
</div>
</div>
@@ -243,7 +264,12 @@
bind:value={aiKey}
on:keyup={handleKeyUp}
/>
<TestAiKey apiKey={aiKey} disabled={!aiKey} aiProvider={selected} />
<TestAIKey
apiKey={aiKey}
disabled={!aiKey}
aiProvider={selected}
model={AI_DEFAULT_MODELS[selected][0]}
/>
</div>
{#if aiKey}
<Toggle
@@ -3,7 +3,7 @@
import { page } from '$app/stores'
import { isCloudHosted } from '$lib/cloud'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, Tab, Tabs } from '$lib/components/common'
import { Alert, Badge, Button, Skeleton, Tab, Tabs } from '$lib/components/common'
import DeployToSetting from '$lib/components/DeployToSetting.svelte'
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
@@ -20,7 +20,8 @@
WorkspaceService,
JobService,
ResourceService,
SettingService
SettingService,
type AIProvider
} from '$lib/gen'
import {
enterpriseLicense,
@@ -50,7 +51,7 @@
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import TestAiKey from '$lib/components/copilot/TestAiKey.svelte'
import TestAIKey from '$lib/components/copilot/TestAIKey.svelte'
import Portal from '$lib/components/Portal.svelte'
import { fade } from 'svelte/transition'
@@ -66,8 +67,11 @@
import { hubPaths } from '$lib/hub'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import type { AiProviderTypes } from '$lib/components/copilot/lib'
import { AI_DEFAULT_MODELS } from '$lib/components/copilot/lib'
import Description from '$lib/components/Description.svelte'
import MultiSelect from 'svelte-multiselect'
import Label from '$lib/components/Label.svelte'
import ArgEnum from '$lib/components/ArgEnum.svelte'
type GitSyncTypeMap = {
scripts: boolean
@@ -113,8 +117,11 @@
let errorHandlerMutedOnCancel: boolean | undefined = undefined
let criticalAlertUIMuted: boolean | undefined = undefined
let initialCriticalAlertUIMuted: boolean | undefined = undefined
let aiResourceInitialPath: string | undefined = undefined
let aiResourceInitialProvider: string | undefined = undefined
let aiResourcePath: string | undefined = undefined
let aiProvider: AIProvider = 'openai'
let aiModels: string[] = []
let codeCompletionModel: string | undefined = undefined
let s3ResourceSettings: S3ResourceSettings = {
resourceType: 's3',
@@ -142,8 +149,6 @@
let editedWorkspaceEncryptionKey: string | undefined = undefined
let workspaceReencryptionInProgress: boolean = false
let encryptionKeyRegex = /^[a-zA-Z0-9]{64}$/
let codeCompletionEnabled: boolean = false
let selected: AiProviderTypes = 'openai'
let tab =
($page.url.searchParams.get('tab') as
| 'users'
@@ -191,10 +196,7 @@
}
}
async function editCopilotConfig(aiResourcePath: string, aiProvider: string): Promise<void> {
// in JS, an empty string is also falsy
aiResourceInitialPath = aiResourcePath
aiResourceInitialProvider = aiProvider
async function editCopilotConfig(): Promise<void> {
if (aiResourcePath) {
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
@@ -203,26 +205,30 @@
path: aiResourcePath,
provider: aiProvider
},
code_completion_enabled: codeCompletionEnabled
code_completion_model: codeCompletionModel,
ai_models: aiModels
}
})
copilotInfo.set({
ai_provider: aiProvider,
exists_ai_resource: true,
code_completion_enabled: codeCompletionEnabled
code_completion_model: codeCompletionModel,
ai_models: aiModels
})
} else {
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
requestBody: {
ai_resource: undefined,
code_completion_enabled: codeCompletionEnabled
code_completion_model: codeCompletionModel,
ai_models: []
}
})
copilotInfo.set({
ai_provider: '',
ai_provider: 'openai',
exists_ai_resource: false,
code_completion_enabled: codeCompletionEnabled
code_completion_model: codeCompletionModel,
ai_models: []
})
}
sendUserToast(`Copilot settings updated`)
@@ -382,6 +388,7 @@
}, 1000 - (timeEnd - timeStart))
}
let loadedSettings = false
async function loadSettings(): Promise<void> {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
team_name = settings.slack_name
@@ -395,9 +402,12 @@
customer_id = settings.customer_id
workspaceToDeployTo = settings.deploy_to
webhook = settings.webhook
aiResourceInitialPath = settings.ai_resource?.path
aiResourceInitialProvider = settings.ai_resource?.provider
selected = (aiResourceInitialProvider as AiProviderTypes) ?? 'openai'
aiResourcePath = settings.ai_resource?.path
aiProvider = settings.ai_resource?.provider ?? 'openai'
codeCompletionModel = settings.code_completion_model
aiModels = settings.ai_models
errorHandlerItemKind = settings.error_handler?.split('/')[0] as 'flow' | 'script'
errorHandlerScriptPath = (settings.error_handler ?? '').split('/').slice(1).join('/')
errorHandlerInitialScriptPath = errorHandlerScriptPath
@@ -415,7 +425,6 @@
: 'custom'
}
errorHandlerExtraArgs = settings.error_handler_extra_args ?? {}
codeCompletionEnabled = settings.code_completion_enabled
workspaceDefaultAppPath = settings.default_app
s3ResourceSettings = convertBackendSettingsToFrontendSettings(settings.large_file_storage)
@@ -508,6 +517,8 @@
workspace: $workspaceStore!,
path: 'openai_client_credentials_oauth'
})
loadedSettings = true
}
let deployUiSettings: {
@@ -698,7 +709,9 @@
</Tab>
</Tabs>
</div>
{#if tab == 'users'}
{#if !loadedSettings}
<Skeleton layout={[1, [40]]} />
{:else if tab == 'users'}
<WorkspaceUserSettings />
{:else if tab == 'deploy_to'}
<div class="flex flex-col gap-4 my-8">
@@ -1044,43 +1057,104 @@
</Description>
</div>
</div>
<ToggleButtonGroup
bind:selected
on:selected={() => {
aiResourceInitialPath = ''
aiResourceInitialProvider = ''
}}
>
<ToggleButton value="openai" label="OpenAI" />
<ToggleButton value="anthropic" label="Anthropic" />
<ToggleButton value="mistral" label="Mistral" />
</ToggleButtonGroup>
<div class="mt-5 flex gap-1">
{#key [aiResourceInitialPath, aiResourceInitialProvider, usingOpenaiClientCredentialsOauth, selected]}
<ResourcePicker
resourceType={usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: selected}
initialValue={aiResourceInitialPath}
on:change={(ev) => {
editCopilotConfig(ev.detail, selected)
}}
/>
<TestAiKey
disabled={!aiResourceInitialPath || aiResourceInitialProvider != selected}
aiProvider={selected}
/>
{/key}
</div>
<div class="mt-3">
<Toggle
class="mr-2"
bind:checked={codeCompletionEnabled}
options={{ right: 'Enable code completion' }}
on:change={() => {
editCopilotConfig(aiResourceInitialPath || '', aiResourceInitialProvider || '')
<div class="flex flex-col gap-4">
<ToggleButtonGroup
bind:selected={aiProvider}
on:selected={() => {
aiResourcePath = ''
aiModels = []
codeCompletionModel = undefined
}}
/>
>
<ToggleButton value="openai" label="OpenAI" />
<ToggleButton value="anthropic" label="Anthropic" />
<ToggleButton value="mistral" label="Mistral" />
<ToggleButton value="deepseek" label="DeepSeek" />
<ToggleButton value="groq" label="Groq" />
<ToggleButton value="openrouter" label="OpenRouter" />
<ToggleButton
value="customai"
label={'Custom AI' + ($enterpriseLicense ? '' : ' (EE)')}
disabled={!$enterpriseLicense}
tooltip="Configure a custom AI provider that is OpenAI API compatible"
showTooltipIcon
/>
</ToggleButtonGroup>
<div class="flex gap-1">
{#key aiProvider}
<ResourcePicker
resourceType={usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: aiProvider}
initialValue={aiResourcePath}
bind:value={aiResourcePath}
on:change={() => {
if (aiResourcePath && aiModels.length === 0) {
if (aiProvider !== 'customai') {
aiModels = AI_DEFAULT_MODELS[aiProvider].slice(0, 1)
}
}
}}
/>
<TestAIKey
disabled={!aiResourcePath || (aiProvider === 'customai' && aiModels.length === 0)}
resourcePath={aiResourcePath}
{aiProvider}
model={aiProvider === 'customai' ? aiModels[0] : AI_DEFAULT_MODELS[aiProvider][0]}
/>
{/key}
</div>
{#if aiResourcePath}
<Label label="Enabled models">
<MultiSelect
options={AI_DEFAULT_MODELS[aiProvider]}
ulOptionsClass={'!bg-surface-secondary'}
allowUserOptions="append"
bind:selected={aiModels}
/>
</Label>
<div class="flex flex-col gap-2">
<Toggle
on:change={() => {
if (codeCompletionModel != undefined) {
codeCompletionModel = undefined
} else {
codeCompletionModel = AI_DEFAULT_MODELS[aiProvider][0] ?? ''
}
}}
checked={codeCompletionModel != undefined}
options={{
right: 'Code completion'
}}
/>
{#if codeCompletionModel != undefined}
<Label label="Code completion model">
<ArgEnum
enum_={AI_DEFAULT_MODELS[aiProvider]}
bind:value={codeCompletionModel}
disabled={false}
autofocus={false}
defaultValue={undefined}
valid={true}
create={true}
required={false}
/>
</Label>
{/if}
</div>
{/if}
<Button
disabled={(aiResourcePath && aiModels.length === 0) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0)}
size="sm"
on:click={editCopilotConfig}
>
Save
</Button>
</div>
{:else if tab == 'windmill_lfs'}
<div class="flex flex-col gap-4 my-8">