From ae45a50eb28be0e47ba57cac95b912580349d6e5 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:09:30 +0200 Subject: [PATCH 01/32] Fix app tutorials (#6728) * Fix tutorial basic * fix other tutorials * nit fix bug with button shrinking * tutorial works backwards * nit delete field on prev * remove empty app duplication and magic code * fix norefreshbar auto binding to false, making app dirty * fix and improve app tutorial * fix background runnable tutorial scroll * fix connection tutorial * mistake * isCurrentlyInTutorial global state * disable component navigation when in tutorial * ci --- .../components/apps/editor/GridEditor.svelte | 8 +- .../lib/components/apps/editor/appUtils.ts | 187 +++++++++++------- .../component/ComponentNavigation.svelte | 4 +- .../contextPanel/ComponentOutputViewer.svelte | 2 +- .../EmptyInlineScript.svelte | 10 +- .../settingsPanel/InputsSpecEditor.svelte | 1 + .../common/button/ConnectionButton.svelte | 3 +- .../lib/components/tutorials/Tutorial.svelte | 5 + .../tutorials/app/AppTutorial.svelte | 99 ++++------ .../app/BackgroundRunnablesTutorial.svelte | 8 +- .../tutorials/app/ConnectionTutorial.svelte | 4 +- .../src/lib/components/tutorials/utils.ts | 162 +-------------- frontend/src/lib/stores.ts | 5 +- .../(root)/(logged)/apps/add/+page.svelte | 42 +--- 14 files changed, 195 insertions(+), 345 deletions(-) diff --git a/frontend/src/lib/components/apps/editor/GridEditor.svelte b/frontend/src/lib/components/apps/editor/GridEditor.svelte index 403660a189..015990c2e4 100644 --- a/frontend/src/lib/components/apps/editor/GridEditor.svelte +++ b/frontend/src/lib/components/apps/editor/GridEditor.svelte @@ -166,7 +166,13 @@
Hide bar on view - + $app.norefreshbar ?? false, + (v) => ($app.norefreshbar !== undefined || v) && ($app.norefreshbar = v) + } + />
{policy.on_behalf_of ? `Author ${policy.on_behalf_of_email}` : ''} diff --git a/frontend/src/lib/components/apps/editor/appUtils.ts b/frontend/src/lib/components/apps/editor/appUtils.ts index d4e2a2e995..5276ff301f 100644 --- a/frontend/src/lib/components/apps/editor/appUtils.ts +++ b/frontend/src/lib/components/apps/editor/appUtils.ts @@ -13,6 +13,8 @@ import { ccomponents, components, getRecommendedDimensionsByComponent, + presets, + processDimension, type AppComponent, type BaseComponent, type InitialAppComponent, @@ -36,17 +38,18 @@ import { sendUserToast } from '$lib/toast' import { getNextId } from '$lib/components/flows/idUtils' import { enterpriseLicense } from '$lib/stores' import gridHelp from '../svelte-grid/utils/helper' +import { DEFAULT_THEME } from './componentsPanel/themeUtils' type GridItemLocation = | { - type: 'grid' - gridItemIndex: number - } + type: 'grid' + gridItemIndex: number + } | { - type: 'subgrid' - subgridItemIndex: number - subgridKey: string - } + type: 'subgrid' + subgridItemIndex: number + subgridKey: string + } interface GridItemWithLocation { location: GridItemLocation item: GridItem @@ -187,7 +190,7 @@ export function selectId( selectedComponent: Writable, app: App ) { - ; (document?.activeElement as HTMLElement)?.blur() + ;(document?.activeElement as HTMLElement)?.blur() if (e.shiftKey) { selectedComponent.update((old) => { if (old && old?.[0]) { @@ -492,11 +495,11 @@ export function appComponentFromType( xData: type === 'plotlycomponentv2' || type === 'chartjscomponentv2' ? { - type: 'evalv2', - fieldType: 'array', - expr: '[1, 2, 3, 4]', - connections: [] - } + type: 'evalv2', + fieldType: 'array', + expr: '[1, 2, 3, 4]', + connections: [] + } : undefined, ...(extra ?? {}) } @@ -845,33 +848,33 @@ export type InitConfig< | EvalAppInput | EvalV2AppInput | { - type: 'oneOf' - selected: string - configuration: Record< - string, - Record - > - } + type: 'oneOf' + selected: string + configuration: Record< + string, + Record + > + } > > = { - [Property in keyof T]: T[Property] extends StaticAppInput + [Property in keyof T]: T[Property] extends StaticAppInput ? T[Property]['value'] | undefined : T[Property] extends { type: 'oneOf' } - ? { - type: 'oneOf' - selected: keyof T[Property]['configuration'] - configuration: { - [Choice in keyof T[Property]['configuration']]: { - [IT in keyof T[Property]['configuration'][Choice]]: T[Property]['configuration'][Choice][IT] extends StaticAppInput - ? T[Property]['configuration'][Choice][IT] extends StaticAppInputOnDemand - ? () => Promise - : T[Property]['configuration'][Choice][IT]['value'] | undefined - : undefined + ? { + type: 'oneOf' + selected: keyof T[Property]['configuration'] + configuration: { + [Choice in keyof T[Property]['configuration']]: { + [IT in keyof T[Property]['configuration'][Choice]]: T[Property]['configuration'][Choice][IT] extends StaticAppInput + ? T[Property]['configuration'][Choice][IT] extends StaticAppInputOnDemand + ? () => Promise + : T[Property]['configuration'][Choice][IT]['value'] | undefined + : undefined + } + } } - } - } - : undefined - } + : undefined +} export function initConfig< T extends Record< @@ -880,13 +883,13 @@ export function initConfig< | EvalAppInput | EvalV2AppInput | { - type: 'oneOf' - selected: string - configuration: Record< - string, - Record - > - } + type: 'oneOf' + selected: string + configuration: Record< + string, + Record + > + } > >( r: T, @@ -894,13 +897,13 @@ export function initConfig< string, | StaticAppInput | { - type: 'oneOf' - selected: string - configuration: Record< - string, - Record - > - } + type: 'oneOf' + selected: string + configuration: Record< + string, + Record + > + } | any > ): InitConfig { @@ -910,31 +913,31 @@ export function initConfig< Object.entries(r).map(([key, value]) => value.type == 'static' ? [ - key, - configuration?.[key]?.type == 'static' ? configuration?.[key]?.['value'] : undefined - ] + key, + configuration?.[key]?.type == 'static' ? configuration?.[key]?.['value'] : undefined + ] : value.type == 'oneOf' ? [ - key, - { - selected: value.selected, - type: 'oneOf', - configuration: Object.fromEntries( - Object.entries(value.configuration).map(([choice, config]) => { - const conf = initConfig( - config, - configuration?.[key]?.configuration?.[choice] - ) - Object.entries(config).forEach(([innerKey, innerValue]) => { - if (innerValue.type === 'static' && !(innerKey in conf)) { - conf[innerKey] = innerValue.value - } + key, + { + selected: value.selected, + type: 'oneOf', + configuration: Object.fromEntries( + Object.entries(value.configuration).map(([choice, config]) => { + const conf = initConfig( + config, + configuration?.[key]?.configuration?.[choice] + ) + Object.entries(config).forEach(([innerKey, innerValue]) => { + if (innerValue.type === 'static' && !(innerKey in conf)) { + conf[innerKey] = innerValue.value + } + }) + return [choice, conf] }) - return [choice, conf] - }) - ) - } - ] + ) + } + ] : [key, undefined] ) ) as any @@ -1395,3 +1398,45 @@ export function animateTo(start: number, end: number, onUpdate: (newValue: numbe function easeInOut(t: number) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t } + +export function emptyApp(): App { + let value: App = { + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [], + theme: { + type: 'path', + path: DEFAULT_THEME + } + } + const preset = presets['topbarcomponent'] + + const id = insertNewGridItem( + value, + appComponentFromType(preset.targetComponent, preset.configuration, undefined, { + customCss: { + container: { + class: '!p-0' as any, + style: '' + } + } + }) as (id: string) => AppComponent, + undefined, + undefined, + 'topbar', + { x: 0, y: 0 }, + { + 3: processDimension(preset.dims, 3), + 12: processDimension(preset.dims, 12) + }, + true, + true + ) + + setUpTopBarComponentContent(id, value) + + value.hideLegacyTopBar = true + value.mobileViewOnSmallerScreens = false + return value +} diff --git a/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte b/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte index aa81b7f5cc..2ac045feaa 100644 --- a/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte +++ b/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte @@ -11,6 +11,7 @@ left } from './componentCallbacks.svelte' import type { AppEditorContext, AppViewerContext } from '../../types' + import { isCurrentlyInTutorial } from '$lib/stores' const { history, movingcomponents, jobsDrawerOpen, runnableJobEditorPanel } = getContext('AppEditorContext') as AppEditorContext @@ -33,7 +34,8 @@ if ( (typeof classes === 'string' && classes.includes('inputarea')) || ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!) || - $runnableJobEditorPanel.focused + $runnableJobEditorPanel.focused || + isCurrentlyInTutorial.val ) { return } diff --git a/frontend/src/lib/components/apps/editor/contextPanel/ComponentOutputViewer.svelte b/frontend/src/lib/components/apps/editor/contextPanel/ComponentOutputViewer.svelte index ffc9e7f750..9baad1f998 100644 --- a/frontend/src/lib/components/apps/editor/contextPanel/ComponentOutputViewer.svelte +++ b/frontend/src/lib/components/apps/editor/contextPanel/ComponentOutputViewer.svelte @@ -52,7 +52,7 @@ {#if render && object != undefined && Object.keys(object).length > 0} {#if $hasResult[componentId] || $search == ''} -
+
diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte index 127abc8311..7d73f0a0d2 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte @@ -193,6 +193,7 @@ {openConnection} isOpen={!!$connectingInput.opened} btnWrapperClasses={'h-6 w-8 opacity-0 group-hover:opacity-100 transition-opacity'} + id="schema-plug-{key}" /> void export let closeConnection: () => void export let btnWrapperClasses = '' + export let id: string | undefined = undefined let selected = false @@ -83,7 +84,7 @@ color="light" title="Connect" on:click={() => handleConnect(true)} - id="schema-plug" + {id} wrapperClasses={twMerge(btnWrapperClasses, selected ? 'opacity-100' : '')} btnClasses="p-0" > diff --git a/frontend/src/lib/components/tutorials/Tutorial.svelte b/frontend/src/lib/components/tutorials/Tutorial.svelte index 94c92420b1..1ef9e2b53c 100644 --- a/frontend/src/lib/components/tutorials/Tutorial.svelte +++ b/frontend/src/lib/components/tutorials/Tutorial.svelte @@ -6,10 +6,12 @@ import SkipTutorials from './SkipTutorials.svelte' import TutorialControls from './TutorialControls.svelte' import TutorialInner from './TutorialInner.svelte' + import { isCurrentlyInTutorial } from '$lib/stores' export let index: number = 0 export let name: string = 'action' export let tainted: boolean = false + export let onDestroyed: (() => void) | undefined = undefined type Options = { indexToInsertAt?: number @@ -111,6 +113,7 @@ dispatch('error', { detail: name }) return } + isCurrentlyInTutorial.val = true tutorial = driver({ allowClose: true, @@ -122,9 +125,11 @@ renderControls({ config, state }) }, onDestroyed: () => { + onDestroyed?.() if (!tutorial?.hasNextStep()) { $ignoredTutorials = Array.from(new Set([...$ignoredTutorials, index])) } + isCurrentlyInTutorial.val = false } }) diff --git a/frontend/src/lib/components/tutorials/app/AppTutorial.svelte b/frontend/src/lib/components/tutorials/app/AppTutorial.svelte index 97039e4af4..1b52905525 100644 --- a/frontend/src/lib/components/tutorials/app/AppTutorial.svelte +++ b/frontend/src/lib/components/tutorials/app/AppTutorial.svelte @@ -13,14 +13,15 @@ updateInlineRunnableCode } from '../utils' import { updateProgress } from '$lib/tutorialUtils' + import { type DriveStep } from 'driver.js' + import { wait } from '$lib/utils' export let name: string export let index: number let tutorial: Tutorial | undefined = undefined - const { app, selectedComponent, focusedGrid, connectingInput } = - getContext('AppViewerContext') + const { app, selectedComponent, focusedGrid } = getContext('AppViewerContext') const { history } = getContext('AppEditorContext') export function runTutorial() { @@ -49,7 +50,7 @@ on:skipAll tainted={isAppTainted($app)} getSteps={(driver) => { - const steps = [ + const steps: DriveStep[] = [ { popover: { title: 'App editor tutorial', @@ -112,7 +113,7 @@ popover: { title: 'Component input', description: - 'There are several ways to set the input of a component. It can be static, the result of a JS expression, connected to the output of another component, or the result of a inline runnable. Here we will create an inline runnable that will convert the text to uppercase.', + 'There are several ways to set the input of a component. It can be static, the result of a JS expression, connected to the output of another component, or the result of an inline runnable. Here we will create an inline runnable that will convert the text to uppercase.', onNextClick: () => { clickFirstButtonBySelector('#component-input') setTimeout(() => { @@ -143,9 +144,7 @@ description: "Let's create an inline script.", onNextClick: () => { clickButtonBySelector('#app-editor-create-inline-script') - setTimeout(() => { - driver.moveNext() - }) + setTimeout(() => driver.moveNext()) } } }, @@ -155,7 +154,7 @@ popover: { title: 'Choose a language', description: - 'You can choose the language of your runnable. They are two type of runnables: frontend and backend.' + 'You can choose the language of your runnable. There are two type of runnables: frontend and backend.' } }, @@ -177,87 +176,71 @@ }, { element: '#create-deno-script', + onHighlighted: () => { + document.querySelector('#schema-plug-x')?.parentElement?.classList.remove('opacity-0') + }, popover: { title: 'Create a deno script', description: "Let's create a simple deno script. For the sake of this tutorial, we will create a script that converts the text to uppercase.", - onNextClick: () => { + onNextClick: async () => { clickButtonBySelector('#create-deno-script') - setTimeout(() => { - if ($selectedComponent?.[0]) { - updateInlineRunnableCode( - $app, - $selectedComponent[0], - `export async function main(x: string) { - return x?.toLocaleUpperCase(); -} -` - ) - } + await wait(50) + if ($selectedComponent?.[0]) { + updateInlineRunnableCode( + $app, + $selectedComponent[0], + 'export function main(x: string) {\n return x?.toLocaleUpperCase();\n}' + ) + } - driver.moveNext() - }) + driver.moveNext() } } }, { - element: '#schema-plug', + element: '#schema-plug-x', + onHighlighted: () => { + document.querySelector('#schema-plug-x')?.parentElement?.classList.remove('opacity-0') + }, popover: { title: 'Connect the function input', description: "The function we created has an string input 'x'. We can connect the output of the text component to it.", onNextClick: () => { - clickButtonBySelector('#schema-plug') + clickButtonBySelector('#schema-plug-x') setTimeout(() => { driver.moveNext() }) } } }, - { - element: '#connect-output-d', + element: '#connect-output-a', popover: { title: 'Select the output', - description: ' ', + description: 'Open the output selector of the text input component.', onNextClick: () => { - $connectingInput.opened = false - $connectingInput.input = undefined - + clickButtonBySelector('#connect-output-a') setTimeout(() => { driver.moveNext() }) - }, - onPopoverRender: (popover, opts) => { - const wrapper = document.createElement('div') - wrapper.classList.add('flex', 'flex-col', 'gap-2', 'w-full', 'items-start') - - const p1 = document.createElement('p') - p1.innerText = - 'You can now select the output in the output menu. Click on the little red button to open the menu.' - - const id = document.createElement('div') - id.innerHTML = `` - - const p2 = document.createElement('p') - p2.innerText = - 'Once opened, you can select the output you want to connect to. Here we will connect the result output of the text component to the input "x" of the inline runnable.' - - const objectViewer = document.createElement('div') - objectViewer.innerHTML = `
` - - wrapper.appendChild(p1) - wrapper.appendChild(id) - wrapper.appendChild(p2) - wrapper.appendChild(objectViewer) - - popover.description.appendChild(wrapper) - - tutorial?.renderControls(opts) } } }, - + { + element: '.component-output-viewer-a li *:has(> button[title="result"])', + popover: { + title: 'Select the output', + description: "Let's select the result of the text input component.", + onNextClick: () => { + setTimeout(async () => { + clickButtonBySelector('.component-output-viewer-a li button[title="result"]') + driver.moveNext() + }) + } + } + }, { element: '.wm-app-viewer', popover: { diff --git a/frontend/src/lib/components/tutorials/app/BackgroundRunnablesTutorial.svelte b/frontend/src/lib/components/tutorials/app/BackgroundRunnablesTutorial.svelte index 7671ac9db1..16524af0b5 100644 --- a/frontend/src/lib/components/tutorials/app/BackgroundRunnablesTutorial.svelte +++ b/frontend/src/lib/components/tutorials/app/BackgroundRunnablesTutorial.svelte @@ -1,5 +1,6 @@ From 59cdb141c339a8fac49462d0ce7aa27c61ce89be Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:10:53 +0200 Subject: [PATCH 02/32] NULL Toggle in InsertRow drawer (#6729) * NULL toggle in InsertRow * fix long type parsing in postgres * nits * graphite catch * lazy_static * support for time/timestamp/tz long forms in pg parser * graphite suggestion --- .../parsers/windmill-parser-sql/src/lib.rs | 40 +++++++++++++++++-- backend/windmill-worker/src/pg_executor.rs | 2 +- frontend/src/lib/common.ts | 1 + frontend/src/lib/components/SchemaForm.svelte | 4 +- .../display/dbtable/InsertRow.svelte | 30 +++++++++++++- .../display/dbtable/queries/insert.ts | 1 - 6 files changed, 70 insertions(+), 8 deletions(-) diff --git a/backend/parsers/windmill-parser-sql/src/lib.rs b/backend/parsers/windmill-parser-sql/src/lib.rs index 5856fbc4fd..7038ddab3c 100644 --- a/backend/parsers/windmill-parser-sql/src/lib.rs +++ b/backend/parsers/windmill-parser-sql/src/lib.rs @@ -2,6 +2,7 @@ use anyhow::anyhow; +use lazy_static::lazy_static; #[cfg(not(target_arch = "wasm32"))] use regex::Regex; #[cfg(target_arch = "wasm32")] @@ -491,13 +492,15 @@ fn parse_pg_file(code: &str) -> anyhow::Result>> { let mut args = vec![]; let mut hm: HashMap = HashMap::new(); for cap in RE_CODE_PGSQL.captures_iter(code) { + let typ = cap + .get(2) + .map(|cap| transform_types_with_spaces(&cap, &code)) + .unwrap_or("text"); hm.insert( cap.get(1) .and_then(|x| x.as_str().parse::().ok()) .ok_or_else(|| anyhow!("Impossible to parse arg digit"))?, - cap.get(2) - .map(|x| x.as_str().to_string()) - .unwrap_or_else(|| "text".to_string()), + typ.to_string(), ); } for (i, v) in hm.iter() { @@ -543,6 +546,37 @@ fn parse_pg_file(code: &str) -> anyhow::Result>> { Ok(Some(args)) } +// The regex doesn't parse types with space such as "character varying" +// So we look for them manually and replace them with their shorter counterpart +fn transform_types_with_spaces<'a>(cap: ®ex::Match<'a>, code: &str) -> &'a str { + lazy_static! { + static ref TYPES: [(&'static str, &'static str); 6] = [ + ("character varying", "varchar"), + ("double precision", "double"), + ("time with time zone", "timetz"), + ("time without time zone", "time"), + ("timestamp with time zone", "timestamptz"), + ("timestamp without time zone", "timestamp"), + ]; + } + let typ = &code[cap.start()..]; + for (long_type, alias) in TYPES.iter() { + let mut typ = typ; + let mut found_mismatch = false; + for token in long_type.split(' ') { + if typ.len() < token.len() || !typ[..token.len()].eq_ignore_ascii_case(token) { + found_mismatch = true; + break; + } + typ = typ[token.len()..].trim_start(); + } + if !found_mismatch { + return alias; + } + } + cap.as_str() +} + pub fn parse_sql_statement_named_params(code: &str, prefix: char) -> HashSet { let mut arg_names = HashSet::new(); run_on_sql_statement_matches( diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 2c7fe123dc..49513d1465 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -88,7 +88,7 @@ fn do_postgresql_inner<'a>( let arg_t = arg .otyp .as_ref() - .ok_or_else(|| anyhow::anyhow!("Missing otzyp for pg arg"))?; + .ok_or_else(|| anyhow::anyhow!("Missing otyp for pg arg"))?; let typ = &arg.typ; let param = convert_val(value, arg_t, typ)?; query_params.push(param); diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 7ea1de146e..f3dc46db7a 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -49,6 +49,7 @@ export interface SchemaProperty { placeholder?: string oneOf?: SchemaProperty[] originalType?: string + disabled?: boolean } export interface ModalSchemaProperty { diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index 932ec78745..95e436bd27 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -70,7 +70,7 @@ | undefined) | undefined workspace?: string | undefined - actions?: import('svelte').Snippet + actions?: import('svelte').Snippet<[{ item: { id: string; value: string } }]> | undefined } let { @@ -414,7 +414,7 @@ {displayType} > {#snippet actions()} - {@render actions_render?.()} + {@render actions_render?.({ item })} {#if linkedSecretCandidates?.includes(argName)}
{#if schema} - + + {#snippet actions({ item })} + {@const disabled = fields?.[fields?.findIndex((f) => f.name === item.id)]?.nullable != 'YES'} + {#if !disabled} + args[item.id] === null, + (v) => { + if (!schema?.properties[item.id]) return + if (v) { + schema.properties[item.id].nullable = true + schema.properties[item.id].disabled = true + args[item.id] = null + } else { + delete schema.properties[item.id].disabled + delete schema.properties[item.id].nullable + args[item.id] = schema.properties[item.id].default ?? '' + } + } + } + /> + {/if} + {/snippet} + {/if} diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts index c13b6c1bc2..ca83586fee 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts @@ -97,7 +97,6 @@ export function makeInsertQuery(table: string, columns: ColumnDef[], dbType: DbT const commaOrEmpty = shouldInsertComma ? ', ' : '' query += `INSERT INTO ${table} (${columnNames}) VALUES (${insertValues}${commaOrEmpty}${defaultValues})` - return query } From 36f2ab47152a8bdf1fd97119f52911a9fa0b4679 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:11:41 +0200 Subject: [PATCH 03/32] UI nits (#6731) * Fix JSON editor resource styling * fix Edit resource type Object json editor * oneOfSelected not auto selecting --- .../src/lib/components/ResourceEditor.svelte | 13 ++----- .../schema/EditableSchemaDrawer.svelte | 37 ++++++++++--------- .../schema/FlowPropertyEditor.svelte | 6 +++ 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index baca88f16c..6ce0c492a3 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -291,10 +291,9 @@
File content ({resourceTypeInfo.format_extension})
-
+
{jsonError}{:else}
{/if} -
- +
+
{/if}
diff --git a/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte b/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte index 1e0180a189..cea82e7dfe 100644 --- a/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte +++ b/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte @@ -223,24 +223,25 @@ {/snippet} {:else} -
- { - try { - schema = JSON.parse(schemaString) - error = '' - } catch (err) { - error = err.message - } - }} - bind:code={schemaString} - lang="json" - autoHeight - automaticLayout - /> +
+ { + try { + schema = JSON.parse(schemaString) + error = '' + } catch (err) { + error = err.message + } + }} + bind:code={schemaString} + lang="json" + autoHeight + automaticLayout + /> +
{#if !emptyString(error)}
{error}
{:else} diff --git a/frontend/src/lib/components/schema/FlowPropertyEditor.svelte b/frontend/src/lib/components/schema/FlowPropertyEditor.svelte index dd676dd99a..f1aea70a63 100644 --- a/frontend/src/lib/components/schema/FlowPropertyEditor.svelte +++ b/frontend/src/lib/components/schema/FlowPropertyEditor.svelte @@ -77,6 +77,12 @@ let oneOfSelected: string | undefined = $state(oneOf?.[0]?.title) + $effect(() => { + if (oneOf?.length && !oneOfSelected) { + oneOfSelected = oneOf[0].title + } + }) + const dispatch = createEventDispatcher() function getResourceTypesFromFormat(format: string | undefined): string[] { From a0bc0ee318a21de77be191c8ee987a645e26a6aa Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:56:37 +0200 Subject: [PATCH 04/32] fix path assigner for nested calls (#6732) --- cli/build.sh | 6 ++- .../src/inline-scripts/extractor.ts | 37 ++++++++++++++----- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/cli/build.sh b/cli/build.sh index 0da1c343a3..79f352409c 100755 --- a/cli/build.sh +++ b/cli/build.sh @@ -16,4 +16,8 @@ set -e echo "Running dnt..." deno run -A dnt.ts -echo "Build complete!" \ No newline at end of file +# Remove .ts extensions after building to go back to the original state +./windmill-utils-internal/remove-ts-ext.sh + +echo "Build complete!" + diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index b64e7ca789..b3b10bcad4 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -1,4 +1,4 @@ -import { newPathAssigner } from "../path-utils/path-assigner"; +import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner"; import { FlowModule } from "../gen/types.gen"; /** @@ -17,20 +17,25 @@ interface InlineScript { * * @param modules - Array of flow modules to process * @param mapping - Optional mapping of module IDs to custom file paths + * @param separator - Path separator to use * @param defaultTs - Default TypeScript runtime to use ("bun" or "deno") + * @param pathAssigner - Optional path assigner to reuse (for nested calls) * @returns Array of inline scripts with their paths and content */ export function extractInlineScripts( modules: FlowModule[], mapping: Record = {}, separator: string = "/", - defaultTs?: "bun" | "deno" + defaultTs?: "bun" | "deno", + pathAssigner?: PathAssigner ): InlineScript[] { - const pathAssigner = newPathAssigner(defaultTs ?? "bun"); + // Create pathAssigner only if not provided (top-level call), but reuse it for nested calls + const assigner = pathAssigner ?? newPathAssigner(defaultTs ?? "bun"); + return modules.flatMap((m) => { if (m.value.type == "rawscript") { let basePath, ext; - [basePath, ext] = pathAssigner.assignPath(m.summary, m.value.language); + [basePath, ext] = assigner.assignPath(m.summary, m.value.language); const path = mapping[m.id] ?? basePath + ext; const content = m.value.content; const r = [{ path: path, content: content }]; @@ -47,25 +52,39 @@ export function extractInlineScripts( m.value.modules, mapping, separator, - defaultTs + defaultTs, + assigner ); } else if (m.value.type == "branchall") { return m.value.branches.flatMap((b) => - extractInlineScripts(b.modules, mapping, separator, defaultTs) + extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner) ); } else if (m.value.type == "whileloopflow") { return extractInlineScripts( m.value.modules, mapping, separator, - defaultTs + defaultTs, + assigner ); } else if (m.value.type == "branchone") { return [ ...m.value.branches.flatMap((b) => - extractInlineScripts(b.modules, mapping, separator, defaultTs) + extractInlineScripts( + b.modules, + mapping, + separator, + defaultTs, + assigner + ) + ), + ...extractInlineScripts( + m.value.default, + mapping, + separator, + defaultTs, + assigner ), - ...extractInlineScripts(m.value.default, mapping, separator, defaultTs), ]; } else { return []; From a00991a293f1a3e10000c3c8435abda174795898 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:26:12 +0200 Subject: [PATCH 05/32] Fix save btn disabled on deletion (#6735) --- .../lib/components/workspaceSettings/DucklakeSettings.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index 9e7dcd22b5..7f819a42fe 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -341,8 +341,11 @@ v === false)} > + Save ducklake settings + From 49f5023fa505ba07dc54a115fedac8b73b8fd03e Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:27:13 +0200 Subject: [PATCH 06/32] exclude postgres BIGSERIAL / auto generated columns (#6734) --- .../display/dbtable/InsertRow.svelte | 6 ++-- .../display/dbtable/queries/insert.ts | 5 ++- .../apps/components/display/table/utils.ts | 35 ++++++++++++------- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte b/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte index 00453468f0..3c7812109f 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/InsertRow.svelte @@ -135,8 +135,10 @@ let fields = $derived( columnDefs ?.filter((t) => { - const shouldFilter = t.isidentity === ColumnIdentity.Always || t?.hideInsert === true - + const shouldFilter = + t.isidentity === ColumnIdentity.Always || + t?.hideInsert === true || + t.defaultvalue?.startsWith('nextval(') // exclude postgres serial/auto increment fields return !shouldFilter }) .map((column) => { diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts index ca83586fee..0933ce4b0d 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts @@ -82,7 +82,9 @@ function shouldOmitColumnInInsert(column: ColumnDef) { export function makeInsertQuery(table: string, columns: ColumnDef[], dbType: DbType) { if (!table) throw new Error('Table name is required') - const columnsInsert = columns.filter((x) => !x.hideInsert) + const columnsInsert = columns.filter( + (x) => !x.hideInsert && !(dbType == 'postgresql' && x.defaultvalue?.startsWith('nextval(')) + ) const columnsDefault = columns.filter((c) => !shouldOmitColumnInInsert(c)) const allInsertColumns = columnsInsert.concat(columnsDefault) @@ -97,6 +99,7 @@ export function makeInsertQuery(table: string, columns: ColumnDef[], dbType: DbT const commaOrEmpty = shouldInsertComma ? ', ' : '' query += `INSERT INTO ${table} (${columnNames}) VALUES (${insertValues}${commaOrEmpty}${defaultValues})` + console.log(query) return query } diff --git a/frontend/src/lib/components/apps/components/display/table/utils.ts b/frontend/src/lib/components/apps/components/display/table/utils.ts index 2cfb668b83..015cdc54db 100644 --- a/frontend/src/lib/components/apps/components/display/table/utils.ts +++ b/frontend/src/lib/components/apps/components/display/table/utils.ts @@ -27,9 +27,9 @@ export abstract class AbstractCellRenderer implements ICellRendererComp { eGui: any protected component: | { - refresh: (params: ICellRendererParams) => void - destroy: () => void - } + refresh: (params: ICellRendererParams) => void + destroy: () => void + } | undefined constructor(parentElement = 'span') { // create empty span (or other element) to place svelte component in @@ -218,18 +218,27 @@ export function transformColumnDefs({ // Set default minWidth based on number of actions (if not wrapping) ...(!wrapActions ? { minWidth: 130 * actions?.length } : {}), // Respect user-specified overrides when placeholder present (these should override defaults) - ...( - actionsIndex > -1 - ? { + ...(actionsIndex > -1 + ? { // keep width/pin/flex/align/hide from placeholder when provided - ...(['width', 'minWidth', 'maxWidth', 'flex', 'pinned', 'headerName', 'cellStyle', 'cellClass', 'autoHeight', 'hide'] - .reduce((acc, key) => { - if (r[actionsIndex] && r[actionsIndex][key] !== undefined) acc[key] = r[actionsIndex][key] - return acc - }, {} as any)) + ...[ + 'width', + 'minWidth', + 'maxWidth', + 'flex', + 'pinned', + 'headerName', + 'cellStyle', + 'cellClass', + 'autoHeight', + 'hide' + ].reduce((acc, key) => { + if (r[actionsIndex] && r[actionsIndex][key] !== undefined) + acc[key] = r[actionsIndex][key] + return acc + }, {} as any) } - : {} - ), + : {}), ...(customActionsHeader?.trim() ? { headerName: customActionsHeader } : {}) } From f545b1d572ad9f3ace564a5e9896adf9b0e058f3 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 18:15:22 +0200 Subject: [PATCH 07/32] Fix ducklake instance DB + Add manual instructions (#6736) * same auth method than worker for tokio_postgres * Add manual setup instructions for Ducklake * clarify instruction --- backend/windmill-api/src/settings.rs | 27 ++++++------ .../workspaceSettings/DucklakeSettings.svelte | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/backend/windmill-api/src/settings.rs b/backend/windmill-api/src/settings.rs index 73936466c0..dac78af29b 100644 --- a/backend/windmill-api/src/settings.rs +++ b/backend/windmill-api/src/settings.rs @@ -42,7 +42,6 @@ use windmill_common::{ }, parse_postgres_url, server::Smtp, - utils::build_arg_str, }; pub fn global_service() -> Router { @@ -617,19 +616,21 @@ async fn create_ducklake_database( // We have to connect to the newly created database as admin to grant permissions let pg_creds = parse_postgres_url(&get_database_url().await?)?; - let Some(wm_pg_pwd) = pg_creds.password else { - return Err(error::Error::BadRequest("Password not found".to_string())); + + let ssl_mode = match pg_creds.ssl_mode.as_deref() { + Some("allow") => "prefer".to_string(), + Some("verify-ca") | Some("verify-full") => "require".to_string(), + Some(s) => s.to_string(), + None => "prefer".to_string(), }; - let conn_str: String = build_arg_str( - &[ - ("host", Some(&pg_creds.host)), - ("port", pg_creds.port.map(|p| p.to_string()).as_deref()), - ("password", Some(&wm_pg_pwd)), - ("user", pg_creds.username.as_deref()), - ("dbname", Some(&dbname)), - ], - " ", - "=", + let conn_str = format!( + "postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}", + user = urlencoding::encode(&pg_creds.username.unwrap_or_else(|| "postgres".to_string())), + password = urlencoding::encode(&pg_creds.password.as_deref().unwrap_or("")), + host = urlencoding::encode(&pg_creds.host), + port = pg_creds.port.unwrap_or(5432), + dbname = dbname, + sslmode = ssl_mode ); let (client, connection) = tokio::time::timeout( std::time::Duration::from_secs(20), diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index 7f819a42fe..51815c12f1 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -78,6 +78,7 @@ import { deepEqual } from 'fast-equals' import Popover from '../meltComponents/Popover.svelte' import TextInput from '../text_input/TextInput.svelte' + import Section from '../Section.svelte' const DEFAULT_DUCKLAKE_CATALOG_NAME = 'ducklake_catalog' @@ -194,6 +195,46 @@ Using an instance catalog is the fastest way to get started with Ducklake. They are public to the instance and can be re-used in other workspaces' Ducklake settings. +
+
+ This is what happens when you create a new Instance catalog with the name + ducklake_catalog. This may be useful to debug issues in case the automatic + setup fails in the middle, but in most cases Windmill will handle it for you. +

+ + If the database ducklake_catalog already exists, assume the setup was already + done and do nothing. Otherwise, connect to the Windmill PostgreSQL as the default user (the + one in your DATABASE_URL, usually 'postgres') and run : +
+ + CREATE DATABASE ducklake_catalog;
+ GRANT CONNECT ON DATABASE ducklake_catalog TO ducklake_user; +
+
+ Then, connect to the ducklake_catalog database with the same user as above (NOT + ducklake_user) and run : + + GRANT USAGE ON SCHEMA public TO ducklake_user;
+ GRANT CREATE ON SCHEMA public TO ducklake_user;
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
+   GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ducklake_user; +
+
+ After doing that, creating a new Ducklake with an Instance catalog named + ducklake_catalog should not prompt you to run the automatic setup, and + everything should work fine. +

+ Note : the ducklake_user is automatically created by Windmill in a migration. Its password is + auto-generated and stored in the database table global_settings with the key + ducklake_user_pg_pwd. +
+
{/if} From 7cd5f26f7047ef76021de368ce91f98f0db60ff5 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:30:52 +0200 Subject: [PATCH 08/32] Clarify Ducklake manual setup instructions (#6737) --- .../workspaceSettings/DucklakeSettings.svelte | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index 51815c12f1..4010424525 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -208,9 +208,15 @@ setup fails in the middle, but in most cases Windmill will handle it for you.

- If the database ducklake_catalog already exists, assume the setup was already - done and do nothing. Otherwise, connect to the Windmill PostgreSQL as the default user (the - one in your DATABASE_URL, usually 'postgres') and run : + If the database ducklake_catalog already exists, Windmill assumes that the + setup was successful and does not do anything. However, it is possible that it failed in the + middle (in which case you should have seen an error pop up during setup). There is no + rollback as the following operations do not work in a transaction. +
+ This is what the setup does : +

+ Connect to the Windmill PostgreSQL as the default user (the one in your DATABASE_URL, usually + 'postgres') and run :
CREATE DATABASE ducklake_catalog;
From 9f40ce2da947327ec8985ed47034dbf02143da9d Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:31:05 +0200 Subject: [PATCH 09/32] SelectDropdown reverse animation + ui nits (#6733) * text-secondary nit * Select Dropdown reverse animation * nit misalignment --- .../src/lib/components/ResourcePicker.svelte | 4 +- frontend/src/lib/components/Toggle.svelte | 6 +- .../components/select/SelectDropdown.svelte | 156 ++++++++++++------ .../workspaceSettings/DucklakeSettings.svelte | 2 + .../user/(user)/workspaces/+page.svelte | 4 +- 5 files changed, 115 insertions(+), 57 deletions(-) diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index 1b2835ad27..06f8979e0f 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -27,6 +27,7 @@ defaultValues?: Record | undefined placeholder?: string | undefined selectInputClass?: string + class?: string onClear?: () => void excludedValues?: string[] } @@ -44,6 +45,7 @@ defaultValues = undefined, placeholder = undefined, selectInputClass = '', + class: className = '', onClear = undefined, excludedValues = undefined }: Props = $props() @@ -180,7 +182,7 @@ }} /> -
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/flows/FlowChatMessage.svelte b/frontend/src/lib/components/flows/FlowChatMessage.svelte new file mode 100644 index 0000000000..44f5a0e625 --- /dev/null +++ b/frontend/src/lib/components/flows/FlowChatMessage.svelte @@ -0,0 +1,48 @@ + + +
+
+ {#if message.message_type === 'user'} +

{message.content}

+ {:else if message.loading} +
+ + Processing... +
+ {:else if message.content} +
+ +
+ {:else} +

No result

+ {/if} +
+
diff --git a/frontend/src/lib/components/flows/FlowConversationsSidebar.svelte b/frontend/src/lib/components/flows/FlowConversationsSidebar.svelte new file mode 100644 index 0000000000..9a7757af0b --- /dev/null +++ b/frontend/src/lib/components/flows/FlowConversationsSidebar.svelte @@ -0,0 +1,248 @@ + + +
+ +
+
+ + +
+
+ + + {#if !isExpanded} + +
+ +
+ {/if} + + +
+ + {#snippet children({ item: conversation, hover })} +
+ + +
+ {/snippet} + + {#snippet empty()} +
+

No conversations yet

+
+ {/snippet} +
+
+ + {#if isExpanded} + +
+

+ {conversations.length} conversation{conversations.length !== 1 ? 's' : ''} +

+
+ {/if} +
diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index 0e1f929df7..851370de4d 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -48,7 +48,7 @@ localModuleStates?: Record testModuleStates?: ModulesTestStates isOwner?: boolean - onTestFlow?: () => void + onTestFlow?: () => Promise isRunning?: boolean onCancelTestFlow?: () => void onOpenPreview?: () => void diff --git a/frontend/src/lib/components/flows/common/FlowCard.svelte b/frontend/src/lib/components/flows/common/FlowCard.svelte index 96bff1f674..f780331a82 100644 --- a/frontend/src/lib/components/flows/common/FlowCard.svelte +++ b/frontend/src/lib/components/flows/common/FlowCard.svelte @@ -9,6 +9,7 @@ noHeader?: boolean flowModuleValue?: FlowModuleValue | undefined header?: import('svelte').Snippet + action?: import('svelte').Snippet children?: import('svelte').Snippet isAgentTool?: boolean } @@ -20,6 +21,7 @@ noHeader = false, flowModuleValue = undefined, header, + action, children, isAgentTool = false }: Props = $props() @@ -28,7 +30,15 @@
{#if !noEditor && !noHeader}
- + {@render header?.()}
diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index 780be61a18..c0fd77fba6 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -25,6 +25,7 @@ title?: string | undefined summary?: string | undefined children?: import('svelte').Snippet + action?: import('svelte').Snippet isAgentTool?: boolean } @@ -33,6 +34,7 @@ title = undefined, summary = $bindable(undefined), children, + action, isAgentTool = false }: Props = $props() @@ -165,4 +167,5 @@
{title}
{/if} {@render children?.()} + {@render action?.()}
diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 0007220030..0b9621ef39 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -29,7 +29,7 @@ onDeployTrigger?: (trigger: Trigger) => void forceTestTab?: Record highlightArg?: Record - onTestFlow?: () => void + onTestFlow?: () => Promise job?: Job isOwner?: boolean suspendStatus?: StateStore> diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 199ef78cbe..ea15d8f471 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -44,17 +44,23 @@ import { refreshStateStore } from '$lib/svelte5Utils.svelte' import type { ScriptLang } from '$lib/gen' import { deepEqual } from 'fast-equals' + import FlowChatInterface from '../FlowChatInterface.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import { AI_AGENT_SCHEMA } from '../flowInfers' + import { nextId } from '../flowModuleNextId' + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' interface Props { noEditor: boolean disabled: boolean - onTestFlow?: () => void + onTestFlow?: () => Promise previewOpen: boolean } let { noEditor, disabled, onTestFlow, previewOpen }: Props = $props() const { flowStore, + flowStateStore, previewArgs, pathStore, initialPathStore, @@ -62,6 +68,9 @@ flowInputEditorState } = getContext('FlowEditorContext') + let chatInputEnabled = $derived(Boolean(flowStore.val.value?.chat_input_enabled)) + let showChatModeWarning = $state(false) + let addPropertyV2: AddPropertyV2 | undefined = $state(undefined) let previewSchema: Record | undefined = $state(undefined) let payloadData: Record | undefined = undefined @@ -207,8 +216,8 @@ } } - function runPreview() { - onTestFlow?.() + async function runPreview() { + await onTestFlow?.() } function updatePreviewSchemaAndArgs(payload: any) { @@ -360,276 +369,395 @@ jsonInputs?.resetSelected(true) firstStepInputs?.resetSelected(true) } + + async function runFlowWithMessage(message: string): Promise { + previewArgs.val = { user_message: message } + const jobId = await onTestFlow?.() + return jobId + } + + function hasOtherInputs(): boolean { + const properties = flowStore.val.schema?.properties + return Boolean( + properties && + Object.keys(properties).length > 0 && + !(Object.keys(properties).length === 1 && Object.keys(properties).includes('user_message')) + ) + } + + function handleToggleChatMode() { + if (!chatInputEnabled) { + // Check if there are existing inputs + if (hasOtherInputs()) { + showChatModeWarning = true + } else { + enableChatMode() + } + } else { + // Disable chat input - remove from flow.value + if (flowStore.val.value) { + flowStore.val.value.chat_input_enabled = false + } + } + } + + function enableChatMode() { + // Enable chat input - set in flow.value + flowStore.val.value.chat_input_enabled = true + + // Set up the schema for chat input + flowStore.val.schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + user_message: { + type: 'string', + description: 'Message from user' + } + }, + required: ['user_message'] + } + const hasAiAgent = flowStore.val.value.modules.some((m) => m.value.type === 'aiagent') + if (!hasAiAgent) { + const aiAgentId = nextId(flowStateStore.val, flowStore.val) + flowStore.val.value.modules = [ + ...flowStore.val.value.modules, + { + id: aiAgentId, + value: { + type: 'aiagent', + tools: [], + input_transforms: Object.keys(AI_AGENT_SCHEMA.properties ?? {}).reduce((accu, key) => { + if (key === 'user_message') { + accu[key] = { type: 'javascript', expr: 'flow_input.user_message' } + } else { + accu[key] = { + type: 'static', + value: undefined + } + } + return accu + }, {}) + } + } + ] + } + showChatModeWarning = false + } + (showChatModeWarning = false)} +> +

+ Enabling Chat Mode will replace all existing flow inputs with a single + user_message + parameter. +

+

+ Your current input configuration will be lost. Are you sure you want to continue? +

+
+ + {#snippet action()} + {#if !disabled} + + {/if} + {/snippet} {#if !disabled} -
- { - addPropertyV2?.handleDeleteArgument([e.detail]) - }} - showDynOpt - displayWebhookWarning - editTab={$flowInputEditorState?.selectedTab} - {previewSchema} - bind:args={previewArgs.val} - bind:editPanelSize={ - () => { - return editPanelSize - }, - (v) => { - if (editPanelSize != v) { - editPanelSize = v +
+ {#if flowStore.val.value?.chat_input_enabled} +
+ { + const newConversationId = crypto.randomUUID() + return newConversationId + }} + /> +
+ {:else} +
+ { + addPropertyV2?.handleDeleteArgument([e.detail]) + }} + showDynOpt + displayWebhookWarning + editTab={$flowInputEditorState?.selectedTab} + {previewSchema} + bind:args={previewArgs.val} + bind:editPanelSize={ + () => { + return editPanelSize + }, + (v) => { + if (editPanelSize != v) { + editPanelSize = v + } + } } - } - } - editPanelInitialSize={$flowInputEditorState?.editPanelSize} - pannelExtraButtonWidth={$flowInputEditorState?.editPanelSize ? tabButtonWidth : 0} - {diff} - disableDnd={!!previewSchema} - on:rejectChange={(e) => { - rejectChange(e.detail).then(() => { - updatePreviewSchema(selectedSchema) - }) - }} - on:acceptChange={(e) => { - acceptChange(e.detail).then(() => { - updatePreviewSchema(selectedSchema) - }) - }} - shouldDispatchChanges={true} - onChange={() => { - if (!previewSchema) { - let args = $state.snapshot(previewArgs.val) - if (!deepEqual(args, savedPreviewArgs)) { - savedPreviewArgs = args - } - } - }} - bind:isValid - bind:dynCode - bind:dynLang - > - {#snippet openEditTab()} -
- - {#snippet close_button()} - - {/snippet} - -
- {/snippet} - {#snippet addProperty()} - {#if !!previewSchema} -
- -
- {:else} - { - handleEditSchema('inputEditor') - editableSchemaForm?.openField(argName) - refreshStateStore(flowStore) - }} - > - {#snippet trigger()} -
- -
- {/snippet} -
- {/if} - {/snippet} - {#snippet extraTab()} - {#if $flowInputEditorState?.selectedTab === 'history'} - { - updatePreviewSchemaAndArgs(undefined) - }} - > - { - updatePreviewSchemaAndArgs(e.detail?.args ?? undefined) - }} - limitPayloadSize - /> - - {:else if $flowInputEditorState?.selectedTab === 'captures'} - { - updatePreviewSchemaAndArgs(undefined) - }} - title="Trigger captures" - > - {#snippet action()} - -
- -
-
- {/snippet} -
- { - updatePreviewSchemaAndArgs(e.detail ?? undefined) - }} - isFlow={true} - headless={true} - addButton={false} - bind:this={captureTable} - limitPayloadSize - /> + editPanelInitialSize={$flowInputEditorState?.editPanelSize} + pannelExtraButtonWidth={$flowInputEditorState?.editPanelSize ? tabButtonWidth : 0} + {diff} + disableDnd={!!previewSchema} + on:rejectChange={(e) => { + rejectChange(e.detail).then(() => { + updatePreviewSchema(selectedSchema) + }) + }} + on:acceptChange={(e) => { + acceptChange(e.detail).then(() => { + updatePreviewSchema(selectedSchema) + }) + }} + shouldDispatchChanges={true} + onChange={() => { + if (!previewSchema) { + let args = $state.snapshot(previewArgs.val) + if (!deepEqual(args, savedPreviewArgs)) { + savedPreviewArgs = args + } + } + }} + bind:isValid + bind:dynCode + bind:dynLang + > + {#snippet openEditTab()} +
+ + {#snippet close_button()} + + {/snippet} +
- - {:else if $flowInputEditorState?.selectedTab === 'savedInputs'} - { - updatePreviewSchemaAndArgs(undefined) - }} - title="Saved inputs" - > - { - updatePreviewSchemaAndArgs(e.detail ?? undefined) - }} - on:isEditing={(e) => { - preventEnter = e.detail - }} - previewArgs={previewArgs.val} - {isValid} - limitPayloadSize - bind:this={savedInputsPicker} - /> - - {:else if $flowInputEditorState?.selectedTab === 'json'} - { - updatePreviewSchemaAndArgs(undefined) - }} - title="Json payload" - > - { - preventEnter = true - }} - on:blur={async () => { - preventEnter = false - }} - on:select={(e) => { - updatePreviewSchemaAndArgs(e.detail ?? undefined) - }} - selected={!!previewArgs.val} - bind:this={jsonInputs} - /> - - {:else if $flowInputEditorState?.selectedTab === 'firstStepInputs'} - { - updatePreviewSchemaAndArgs(undefined) - connectFirstNode = () => {} - }} - title="First step's inputs" - > - { - connectFirstNode = detail.connectFirstNode - }} - on:select={(e) => { - if (e.detail) { - const diffSchema = computeDiff(e.detail, flowStore.val.schema) - diff = diffSchema - previewSchema = schemaFromDiff(diffSchema, flowStore.val.schema) - runDisabled = true - } else { + {/snippet} + {#snippet addProperty()} + {#if !!previewSchema} +
+ +
+ {:else} + { + handleEditSchema('inputEditor') + editableSchemaForm?.openField(argName) + refreshStateStore(flowStore) + }} + > + {#snippet trigger()} +
+ +
+ {/snippet} +
+ {/if} + {/snippet} + {#snippet extraTab()} + {#if $flowInputEditorState?.selectedTab === 'history'} + { updatePreviewSchemaAndArgs(undefined) - } - }} - /> - - {/if} - {/snippet} - {#snippet runButton()} -
- -
- {/snippet} - + }} + > + { + updatePreviewSchemaAndArgs(e.detail?.args ?? undefined) + }} + limitPayloadSize + /> +
+ {:else if $flowInputEditorState?.selectedTab === 'captures'} + { + updatePreviewSchemaAndArgs(undefined) + }} + title="Trigger captures" + > + {#snippet action()} +
+ +
+ {/snippet} +
+ { + updatePreviewSchemaAndArgs(e.detail ?? undefined) + }} + isFlow={true} + headless={true} + addButton={false} + bind:this={captureTable} + limitPayloadSize + /> +
+
+ {:else if $flowInputEditorState?.selectedTab === 'savedInputs'} + { + updatePreviewSchemaAndArgs(undefined) + }} + title="Saved inputs" + > + { + updatePreviewSchemaAndArgs(e.detail ?? undefined) + }} + on:isEditing={(e) => { + preventEnter = e.detail + }} + previewArgs={previewArgs.val} + {isValid} + limitPayloadSize + bind:this={savedInputsPicker} + /> + + {:else if $flowInputEditorState?.selectedTab === 'json'} + { + updatePreviewSchemaAndArgs(undefined) + }} + title="Json payload" + > + { + preventEnter = true + }} + on:blur={async () => { + preventEnter = false + }} + on:select={(e) => { + updatePreviewSchemaAndArgs(e.detail ?? undefined) + }} + selected={!!previewArgs.val} + bind:this={jsonInputs} + /> + + {:else if $flowInputEditorState?.selectedTab === 'firstStepInputs'} + { + updatePreviewSchemaAndArgs(undefined) + connectFirstNode = () => {} + }} + title="First step's inputs" + > + { + connectFirstNode = detail.connectFirstNode + }} + on:select={(e) => { + if (e.detail) { + const diffSchema = computeDiff(e.detail, flowStore.val.schema) + diff = diffSchema + previewSchema = schemaFromDiff(diffSchema, flowStore.val.schema) + runDisabled = true + } else { + updatePreviewSchemaAndArgs(undefined) + } + }} + /> + + {/if} + {/snippet} + {#snippet runButton()} +
+ +
+ {/snippet} + +
+ {/if}
{:else}
diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index f69675840b..8c1b1b1c94 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -40,6 +40,7 @@ let dirtyPath = $state(false) let displayWorkerTagPicker = $state(false) + run(() => { flowStore.val.tag ? (displayWorkerTagPicker = true) : null }) diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index ee25a6338e..652713f7d7 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -4,6 +4,82 @@ import type { Schema } from '$lib/common' import { emptySchema } from '$lib/utils' import type { FlowModule, InputTransform } from '$lib/gen' +export const AI_AGENT_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { + provider: { + type: 'object', + format: 'ai-provider' + }, + output_type: { + type: 'string', + description: + 'The type of output the AI agent will generate (text or image). Image output requires a configured workspace S3 storage, will ignore tools, and only works with OpenAI, Google AI and OpenRouter gemini-image-preview model.', + enum: ['text', 'image'], + default: 'text' + }, + user_message: { + type: 'string', + description: + 'The message to give as input to the AI agent. You can turn on chat input mode on the input interface to link this field to the message sent by the user.' + }, + system_prompt: { + type: 'string', + description: 'The system prompt to give as input to the AI agent.' + }, + streaming: { + type: 'boolean', + description: 'Whether to stream the output of the AI agent.', + default: false, + showExpr: "fields.output_type === 'text'" + }, + messages_context_length: { + type: 'number', + description: + 'Maximum number of conversation messages to store and retrieve from memory. If not set or 0, memory is disabled.', + showExpr: "fields.output_type === 'text'" + }, + output_schema: { + type: 'object', + description: 'JSON schema that the AI agent will follow for its response format.', + format: 'json-schema', + showExpr: "fields.output_type === 'text'" + }, + user_images: { + type: 'array', + description: + 'Array of images to give as input to the AI agent. Requires a configured workspace S3 storage.', + items: { + type: 'object' as const, + resourceType: 's3object' + } + }, + max_completion_tokens: { + type: 'number', + description: 'The maximum number of output tokens.' + }, + temperature: { + type: 'number', + description: + 'Controls randomness in text generation. Range: 0.0 (deterministic) to 2.0 (random).', + showExpr: "fields.output_type === 'text'" + } + }, + required: ['provider', 'user_message', 'output_type'], + type: 'object', + order: [ + 'provider', + 'output_type', + 'user_message', + 'system_prompt', + 'messages_context_length', + 'output_schema', + 'user_images', + 'max_completion_tokens', + 'temperature' + ] +} + export async function loadSchemaFromModule(module: FlowModule): Promise<{ input_transforms: Record schema: Schema @@ -55,85 +131,16 @@ export async function loadSchemaFromModule(module: FlowModule): Promise<{ schema: schema ?? emptySchema() } } else if (mod.type === 'aiagent') { - const schema = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: { - provider: { - type: 'object', - format: 'ai-provider' - }, - output_type: { - type: 'string', - description: - 'The type of output the AI agent will generate (text or image). Image output requires a configured workspace S3 storage, will ignore tools, and only works with OpenAI, Google AI and OpenRouter gemini-image-preview model.', - enum: ['text', 'image'], - default: 'text' - }, - user_message: { - type: 'string', - description: 'The message to give as input to the AI agent.' - }, - system_prompt: { - type: 'string', - description: 'The system prompt to give as input to the AI agent.' - }, - streaming: { - type: 'boolean', - description: - 'Whether to stream the output of the AI agent (only used if output_type is text).', - default: false, - showExpr: "fields.output_type === 'text'" - }, - user_images: { - type: 'array', - description: - 'Array of images to give as input to the AI agent. Requires a configured workspace S3 storage.', - items: { - type: 'object' as const, - resourceType: 's3object' - } - }, - max_completion_tokens: { - type: 'number', - description: 'The maximum number of output tokens.' - }, - temperature: { - type: 'number', - description: - 'Controls randomness in text generation. Range: 0.0 (deterministic) to 2.0 (random).', - showExpr: "fields.output_type === 'text'" - }, - output_schema: { - type: 'object', - description: - 'JSON schema that the AI agent will follow for its response format (only used if output_type is text).', - format: 'json-schema', - showExpr: "fields.output_type === 'text'" - } - }, - required: ['provider', 'user_message', 'output_type'], - type: 'object', - order: [ - 'provider', - 'output_type', - 'user_message', - 'system_prompt', - 'user_images', - 'max_completion_tokens', - 'temperature', - 'output_schema' - ] - } let input_transforms = mod.input_transforms ?? {} return { - input_transforms: Object.keys(schema?.properties ?? {}).reduce((accu, key) => { + input_transforms: Object.keys(AI_AGENT_SCHEMA.properties ?? {}).reduce((accu, key) => { accu[key] = input_transforms[key] ?? { type: 'static', value: undefined } return accu }, {}), - schema + schema: AI_AGENT_SCHEMA } } diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index b465efde77..ee33680c4e 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -50,14 +50,14 @@ flowPreviewContent?.test() } - export async function runPreview() { + export async function runPreview(): Promise { if (!previewOpen) { deferContent = true await tick() } previewMode = 'whole' flowPreviewContent?.refresh() - flowPreviewContent?.test() + return await flowPreviewContent?.test() } export function cancelTest() { diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index 78978db0db..c9debf8f99 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -402,6 +402,7 @@ {showJobStatus} suspendStatus={suspendStatus.val} {flowHasChanged} + chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)} onDelete={(id) => { dependents = getDependentComponents(id, flowStore.val) const cb = () => { diff --git a/frontend/src/lib/components/flows/map/InsertModuleButton.svelte b/frontend/src/lib/components/flows/map/InsertModuleButton.svelte index 67f0f2ef95..affa5fed8e 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleButton.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleButton.svelte @@ -41,6 +41,7 @@ } let open = $state(false) + $effect(() => { !open && (funcDesc = '') }) @@ -72,7 +73,9 @@ shouldUsePortal={true} --> 'w-[17.5px] h-[17.5px] flex items-center justify-center !outline-[1px] outline dark:outline-gray-500 outline-gray-300 text-secondary bg-surface focus:outline-none hover:bg-surface-hover rounded', clazz )} - onpointerdown={() => (open = !open)} + onpointerdown={() => { + open = !open + }} > {#if kind === 'trigger'} diff --git a/frontend/src/lib/components/flows/map/VirtualItem.svelte b/frontend/src/lib/components/flows/map/VirtualItem.svelte index aa63e64eef..239732bb2d 100644 --- a/frontend/src/lib/components/flows/map/VirtualItem.svelte +++ b/frontend/src/lib/components/flows/map/VirtualItem.svelte @@ -117,13 +117,12 @@
{#if icon} {@render icon?.()} - {/if}
{#if label} diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 4788da7604..2daa40d275 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -102,6 +102,7 @@ flowJob?: Job | undefined showJobStatus?: boolean suspendStatus?: Record + chatInputEnabled?: boolean onDelete?: (id: string) => void onInsert?: (detail: { sourceId?: string @@ -180,7 +181,8 @@ flowJob = undefined, showJobStatus = false, suspendStatus = {}, - flowHasChanged = false + flowHasChanged = false, + chatInputEnabled = false }: Props = $props() setContext<{ @@ -484,6 +486,7 @@ showJobStatus, suspendStatus, flowHasChanged, + chatInputEnabled, additionalAssetsMap: flowGraphAssetsCtx?.val.additionalAssetsMap }, untrack(() => failureModule), diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index acfdb7d4b2..f2c21ee55b 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -124,6 +124,7 @@ export type InputN = { flowJob: Job | undefined showJobStatus: boolean flowHasChanged: boolean + chatInputEnabled: boolean assets?: AssetWithAltAccessType[] | undefined } } @@ -374,6 +375,7 @@ export function graphBuilder( showJobStatus: boolean suspendStatus: Record flowHasChanged: boolean + chatInputEnabled: boolean additionalAssetsMap?: Record }, failureModule: FlowModule | undefined, @@ -548,6 +550,7 @@ export function graphBuilder( flowJob: extra.flowJob, showJobStatus: extra.showJobStatus, flowHasChanged: extra.flowHasChanged, + chatInputEnabled: extra.chatInputEnabled, ...(inputAssets ? { assets: inputAssets } : {}) } } diff --git a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte index ff5baf6308..aba2b181e5 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte @@ -9,6 +9,7 @@ import { schemaToObject } from '$lib/schema' import type { Schema } from '$lib/common' import type { FlowEditorContext } from '$lib/components/flows/types' + import { MessageSquare } from 'lucide-svelte' interface Props { data: InputN['data'] @@ -28,6 +29,8 @@ ? schemaToObject(flowStore?.val.schema as Schema, previewArgs.val || {}) : undefined ) + + let inputLabel = $derived(data.chatInputEnabled ? 'Chat message' : 'Input') @@ -59,7 +62,9 @@
{/if} + > + {#snippet icon()} + {#if data.chatInputEnabled} + + {/if} + {/snippet} + {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte index ad0ace5868..048f39d9c4 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte @@ -1,6 +1,5 @@
+ {#if chatInputEnabled} +
+ + This flow will only accept user_message as input parameter. + +
+ {/if} diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index 5348db89b5..2f26a4b7bb 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -186,7 +186,7 @@
Ducklake
Windmill has first class support for Ducklake. You can use and explore ducklakes like a normal - SQL database, even through the data is actually stored in parquet files in S3 ! + SQL database, even though the data is actually stored in parquet files in S3 !
diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 225bccff5b..8b4b289d95 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -17,6 +17,8 @@ import { Badge as HeaderBadge, Alert } from '$lib/components/common' import MoveDrawer from '$lib/components/MoveDrawer.svelte' import RunForm from '$lib/components/RunForm.svelte' + import FlowChatInterface from '$lib/components/flows/FlowChatInterface.svelte' + import FlowConversationsSidebar from '$lib/components/flows/FlowConversationsSidebar.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' @@ -197,6 +199,17 @@ } } + async function runFlowForChat(userMessage: string, conversationId: string): Promise { + const run = await JobService.runFlowByPath({ + workspace: $workspaceStore!, + path, + memoryId: conversationId, + requestBody: { user_message: userMessage }, + skipPreprocessor: true + }) + return run + } + let args: Record | undefined = $state(undefined) let hash = window.location.hash @@ -384,11 +397,66 @@ } } let stepDetail: FlowModule | string | undefined = $state(undefined) - + let flowChatInterface: FlowChatInterface | undefined = $state(undefined) + let flowConversationsSidebar: FlowConversationsSidebar | undefined = $state(undefined) let rightPaneSelected = $state('saved_inputs') let savedInputsV2: SavedInputsV2 | undefined = $state(undefined) let flowHistory: FlowHistory | undefined = $state(undefined) + let selectedConversationId: string | undefined = $state(undefined) let path = $derived(page.params.path ?? '') + + async function handleNewConversation({ clearMessages = true }: { clearMessages?: boolean }) { + const newConversationId = crypto.randomUUID() + + // Add the new conversation to the sidebar (returns id of draft or new conversation) + if (flowConversationsSidebar) { + const actualConversationId = await flowConversationsSidebar.addNewConversation( + newConversationId, + $userStore?.username || 'anonymous' + ) + selectedConversationId = actualConversationId + } else { + selectedConversationId = newConversationId + } + + // Clear messages in the chat interface + if (flowChatInterface && clearMessages) { + flowChatInterface.clearMessages() + } + + return newConversationId + } + + async function handleSelectConversation(conversationId: string, isDraft?: boolean) { + selectedConversationId = conversationId + // Load conversation messages into chat interface + if (flowChatInterface) { + if (isDraft) { + // For draft conversations, just clear messages (don't try to load from backend) + flowChatInterface.clearMessages() + } else { + // For persisted conversations, load messages from backend + await flowChatInterface.loadConversationMessages(conversationId) + } + } + } + + async function refreshConversations() { + if (flowConversationsSidebar) { + await flowConversationsSidebar.refreshConversations() + } + } + + function handleDeleteConversation(conversationId: string) { + if (selectedConversationId === conversationId) { + selectedConversationId = undefined + // Clear chat interface since we deleted the selected conversation + if (flowChatInterface) { + flowChatInterface.clearMessages() + } + } + } + $effect(() => { const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli') if (cliTrigger) { @@ -410,6 +478,16 @@ } }) let mainButtons = $derived(getMainButtons(flow, args)) + let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false) + let shouldUseStreaming = $derived.by(() => { + const modules = flow?.value?.modules + const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined + return ( + lastModule?.value?.type === 'aiagent' && + lastModule?.value?.input_transforms?.streaming?.type === 'static' && + lastModule?.value?.input_transforms?.streaming?.value === true + ) + }) @@ -429,6 +507,7 @@ -
+
+
{#if flow?.archived} This flow was archived {/if} @@ -528,68 +609,100 @@
{/if} -
-
- { - savedInputsV2?.resetSelected() - }} - {inputSelected} - /> - { - runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) - }} + {#if chatInputEnabled} + +
+
+ +
+
+ +
+
+ {:else} + +
+
+ { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + { + runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) + }} + /> +
+ + {#if flow.schema?.prompt_for_ai !== undefined} + { + goto(`/flows/edit/${flow?.path}`) + }} + runnableType="flow" + /> + {/if} + +
- {#if flow.schema?.prompt_for_ai !== undefined} - { - goto(`/flows/edit/${flow?.path}`) - }} - runnableType="flow" - /> +
+ + {#if !emptyString(flow.summary)} +
+ {flow.path} +
{/if} - - -
- -
- - {#if !emptyString(flow.summary)} -
- {flow.path} +
+ + Edited by {flow.edited_by} +
{/if} -
- - Edited by {flow.edited_by} - -
{ + if (chatInputEnabled) { + flowChatInterface?.fillInputMessage(e.detail.user_message) + return + } const nargs = JSON.parse(JSON.stringify(e.detail)) args = nargs }} diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 1b04caca64..7aee72475f 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -62,6 +62,9 @@ components: type: number early_return: type: string + chat_input_enabled: + type: boolean + description: Whether this flow accepts chat-style input required: - modules From 06b152b295cd4892d7309651d382a05cdcf7d378 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:53:10 +0200 Subject: [PATCH 16/32] fix: top level assigment doesn't propagate to setContext (#6745) --- frontend/src/lib/components/FlowStatusViewer.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/FlowStatusViewer.svelte b/frontend/src/lib/components/FlowStatusViewer.svelte index 70c331a80b..20587b6be2 100644 --- a/frontend/src/lib/components/FlowStatusViewer.svelte +++ b/frontend/src/lib/components/FlowStatusViewer.svelte @@ -67,7 +67,7 @@ let globalRefreshes: Record Promise)[]> = $state({}) setContext('FlowStatusViewer', { - flowState: flowState, + flowState, suspendStatus, retryStatus, hideDownloadInGraph, @@ -88,7 +88,7 @@ retryStatus.val = {} suspendStatus.val = {} globalRefreshes = {} - flowState = {} + for (let key in localModuleStates) delete flowState[key] localDurationStatuses = {} localModuleStates = {} } From 1913979012efce55760d245993cef346f87602b4 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:13:28 +0200 Subject: [PATCH 17/32] fix build (#6746) --- backend/windmill-worker/src/memory_oss.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/windmill-worker/src/memory_oss.rs b/backend/windmill-worker/src/memory_oss.rs index beda196fbe..2444cd0ebf 100644 --- a/backend/windmill-worker/src/memory_oss.rs +++ b/backend/windmill-worker/src/memory_oss.rs @@ -1,13 +1,13 @@ -#[cfg(feature = "private")] +#[cfg(all(feature = "private", feature = "enterprise"))] #[allow(unused)] pub use crate::memory_ee::*; -#[cfg(not(feature = "private"))] +#[cfg(not(all(feature = "private", feature = "enterprise")))] use {crate::ai::types::OpenAIMessage, crate::memory_common, uuid::Uuid}; /// Read AI agent memory from storage /// In OSS: always reads from disk -#[cfg(not(feature = "private"))] +#[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn read_from_memory( workspace_id: &str, conversation_id: Uuid, @@ -18,7 +18,7 @@ pub async fn read_from_memory( /// Write AI agent memory to storage /// In OSS: always writes to disk -#[cfg(not(feature = "private"))] +#[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn write_to_memory( workspace_id: &str, conversation_id: Uuid, @@ -34,7 +34,7 @@ pub async fn write_to_memory( /// Delete all memory for a conversation from storage /// In OSS: always deletes from disk -#[cfg(not(feature = "private"))] +#[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn delete_conversation_memory( workspace_id: &str, conversation_id: Uuid, From c658f321d68e2d72622d9d167b20cac67364651c Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 3 Oct 2025 12:25:48 -0400 Subject: [PATCH 18/32] fix: show that user is disabled in workspacelist (#6748) * fix: show that user is disabled in workspacelist * Update SQLx metadata --------- Co-authored-by: windmill-internal-app[bot] --- ...536d2fd8a25fe4cd57c223db7d744493f8470c6.json} | 12 +++++++++--- backend/windmill-api/openapi.yaml | 3 +++ backend/windmill-api/src/workspaces.rs | 4 +++- .../lib/components/sidebar/WorkspaceMenu.svelte | 11 ++++++++--- frontend/src/lib/stores.ts | 4 +++- .../(logged)/user/(user)/workspaces/+page.svelte | 16 +++++++++++++--- 6 files changed, 39 insertions(+), 11 deletions(-) rename backend/.sqlx/{query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json => query-c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6.json} (67%) diff --git a/backend/.sqlx/query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json b/backend/.sqlx/query-c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6.json similarity index 67% rename from backend/.sqlx/query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json rename to backend/.sqlx/query-c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6.json index d284956ca1..bb80e8d19a 100644 --- a/backend/.sqlx/query-d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77.json +++ b/backend/.sqlx/query-c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "operator_settings", "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "disabled", + "type_info": "Bool" } ], "parameters": { @@ -45,8 +50,9 @@ false, true, true, - null + null, + false ] }, - "hash": "d0037961e8e787c4277afc3eb79f3b72e3323f878387de8b6fa31493f1215a77" + "hash": "c095a9658c542efc9f0255a1b536d2fd8a25fe4cd57c223db7d744493f8470c6" } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 036637e4de..e804b651e7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -18010,11 +18010,14 @@ components: created_by: type: string nullable: true + disabled: + type: boolean required: - id - name - username - color + - disabled required: - email - workspaces diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 537cff8002..7d06cea031 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -366,6 +366,7 @@ struct UserWorkspace { pub color: Option, pub operator_settings: Option>, pub parent_workspace_id: Option, + pub disabled: bool, } #[derive(Deserialize)] @@ -2109,7 +2110,8 @@ async fn user_workspaces( let workspaces = sqlx::query_as!( UserWorkspace, "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, - CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings + CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, + usr.disabled FROM workspace JOIN usr ON usr.workspace_id = workspace.id JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 60f377f224..c1d0ce1c69 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -140,12 +140,17 @@ { - await toggleSwitchWorkspace(workspace.id) + if (!workspace.disabled) { + await toggleSwitchWorkspace(workspace.id) + } }} {item} > @@ -166,7 +171,7 @@ isForked ? 'text-secondary' : 'text-primary' )} > - {workspace.name} + {workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
> = derived( name: 'Admins', username: 'superadmin', color: undefined, - operator_settings: undefined + operator_settings: undefined, + disabled: false } ] } else { diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 03990ec897..e1c783cc9b 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -70,7 +70,7 @@ async function loadWorkspacesAsAdmin() { workspaces = (await WorkspaceService.listWorkspacesAsSuperAdmin({ perPage: 1000 })).map((x) => { - return { ...x, username: 'superadmin' } + return { ...x, username: 'superadmin', disabled: false } }) } @@ -205,9 +205,16 @@
{/snippet} + + {/if} @@ -1100,59 +996,6 @@ /> closeSaveDrawer()}> - {#if !onLatest} - - By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff. - -
- {/if} - Summary -
- - { - if ($appPath == '' && $summary?.length > 0 && !dirtyPath) { - path?.setName( - $summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - }} - /> -
-
- Deployment message -
- - -
-
- Path - - {#snippet actions()}
{/snippet} -
- - A viewer of the app will execute the runnables of the app on behalf of the publisher (you) - - It ensures that all required resources/runnable visible for publisher but not for viewer at - time of creating the app would prevent the execution of the app. To guarantee tight - security, a policy is computed at time of deployment of the app which only allow the - scripts/flows referred to in the app to be called on behalf of. Furthermore, static - parameters are not overridable. Hence, users will only be able to use the app as intended by - the publisher without risk for leaking resources not used in the app. - - - -
- -

Public URL

- -
-
- { - policy.execution_mode = e.detail ? 'anonymous' : 'publisher' - setPublishState() - }} - disabled={$appPath == ''} - /> -
- {#if $appPath == ''} - - {:else if secretUrlHref} - - {:else} - {/if} -
- Share this url directly or embed it using an iframe (if requiring login, top-level domain of - embedding app must be the same as the one of Windmill) -
- -
- {#if !($userStore?.is_admin || $userStore?.is_super_admin)} - - Custom path can only be set by workspace admins - -
- {/if} - {#if !$enterpriseLicense} -
- - EE only Enterprise Edition only feature -
- {/if} - { - customPath = detail ? '' : undefined - if (customPath === undefined) { - customPathError = '' - } - }} - checked={customPath !== undefined} - options={{ - right: 'Use a custom URL' - }} - disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)} - /> - - {#if customPath !== undefined} -
-
Custom path
-
- { - dirtyCustomPath = true - }} - /> -
-
Custom public URL
-
- - -
{dirtyCustomPath ? customPathError : ''} -
- {/if} -
-
- - You will still need to deploy the app to make visible the latest changes - - - Embed this app in your own product to be used by your own users +
diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte new file mode 100644 index 0000000000..9b6f5a9b2f --- /dev/null +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -0,0 +1,274 @@ + + +{#if !onLatest} + + By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff. + +
+{/if} +Summary +
+ + { + e.stopPropagation() + }} + onkeyup={() => { + if (appPath == '' && summary?.length > 0 && !dirtyPath) { + path?.setName( + summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/-+/g, '_') + .replace(/^-|-$/g, '') + ) + } + }} + /> +
+
+Deployment message +
+ + +
+
+Path + + +
+ + A viewer of the app will execute the runnables of the app on behalf of the publisher (you) + + It ensures that all required resources/runnable visible for publisher but not for viewer at time + of creating the app would prevent the execution of the app. To guarantee tight security, a + policy is computed at time of deployment of the app which only allow the scripts/flows referred + to in the app to be called on behalf of. Furthermore, static parameters are not overridable. + Hence, users will only be able to use the app as intended by the publisher without risk for + leaking resources not used in the app. + + + +
+ +{#if !hideSecretUrl} +

Public URL

+ +
+
+ { + policy.execution_mode = e.detail ? 'anonymous' : 'publisher' + setPublishState() + }} + disabled={appPath == ''} + /> +
+ {#if appPath == ''} + + {:else if secretUrlHref} + + {:else} + {/if} +
+ Share this url directly or embed it using an iframe (if requiring login, top-level domain of + embedding app must be the same as the one of Windmill) +
+ +
+ {#if !($userStore?.is_admin || $userStore?.is_super_admin)} + + Custom path can only be set by workspace admins + +
+ {/if} + {#if !$enterpriseLicense} +
+ + EE only Enterprise Edition only feature +
+ {/if} + { + customPath = detail ? '' : undefined + if (customPath === undefined) { + customPathError = '' + } + }} + checked={customPath !== undefined} + options={{ + right: 'Use a custom URL' + }} + disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)} + /> + + {#if customPath !== undefined} +
+
Custom path
+
+ { + dirtyCustomPath = true + }} + /> +
+
Custom public URL
+
+ + +
{dirtyCustomPath ? customPathError : ''} +
+ {/if} +
+
+ + You will still need to deploy the app to make visible the latest changes + + + Embed this app in your own product to be used by your own users +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeployInitialDraft.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeployInitialDraft.svelte new file mode 100644 index 0000000000..9552436e84 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeployInitialDraft.svelte @@ -0,0 +1,50 @@ + + + + Choose a path to save the initial draft of the app. + +

Summary

+
+ + { + e.stopPropagation() + }} + bind:value={$summary} + onkeyup={() => { + if ($appPath == '' && $summary?.length > 0 && !dirtyPath) { + path?.setName( + $summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/-+/g, '_') + .replace(/^-|-$/g, '') + ) + } + }} + /> +
+
+ +
diff --git a/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts b/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts new file mode 100644 index 0000000000..122c42bcf9 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts @@ -0,0 +1,7 @@ +import { base } from "$lib/base" +import { workspaceStore } from "$lib/stores" +import { get } from "svelte/store" + +export function computeSecretUrl(secretUrl: string) { + return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}` +} diff --git a/frontend/src/lib/components/raw_apps/FileEditorIcon.svelte b/frontend/src/lib/components/raw_apps/FileEditorIcon.svelte index f1419b32c7..1c06463bf1 100644 --- a/frontend/src/lib/components/raw_apps/FileEditorIcon.svelte +++ b/frontend/src/lib/components/raw_apps/FileEditorIcon.svelte @@ -6,7 +6,11 @@ import SvelteIcon from '../icons/SvelteIcon.svelte' import VueIcon from '../icons/VueIcon.svelte' - export let file: string + interface Props { + file: string + } + + let { file }: Props = $props() {#if file.endsWith('.tsx')} diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index c80bd2c41b..1650eb225a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -5,13 +5,25 @@ import type { HiddenRunnable, JobById } from '../apps/types' import { JobService } from '$lib/gen' - export let iframe: HTMLIFrameElement | undefined - export let path: string - export let runnables: Record - export let jobs: string[] = [] - export let jobsById: Record = {} - export let editor: boolean - export let workspace: string + interface Props { + iframe: HTMLIFrameElement | undefined + path: string + runnables: Record + jobs?: string[] + jobsById?: Record + editor: boolean + workspace: string + } + + let { + iframe, + path, + runnables, + jobs = $bindable([]), + jobsById = $bindable({}), + editor, + workspace + }: Props = $props() let listener = async (event) => { const data = event.data @@ -87,4 +99,4 @@ } - + diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 1a24c438a6..dd2ec62cd1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -2,7 +2,6 @@ import { run } from 'svelte/legacy' import { Pane, Splitpanes } from 'svelte-splitpanes' - import { writable } from 'svelte/store' import RawAppInlineScriptsPanel from './RawAppInlineScriptsPanel.svelte' import type { HiddenRunnable, JobById } from '../apps/types' import RawAppEditorHeader from './RawAppEditorHeader.svelte' @@ -52,7 +51,7 @@ }: Props = $props() export const version: number | undefined = undefined - let runnables = writable(initRunnables) + let runnables = $state(initRunnables) let files: Record | undefined = $state(initFiles) @@ -65,7 +64,7 @@ path != '' ? `rawapp-${path}` : 'rawapp', encodeState({ files, - runnables: $runnables + runnables: runnables }) ) } catch (err) { @@ -97,7 +96,7 @@ iframe?.contentWindow?.postMessage( { type: 'setRunnables', - dts: genWmillTs($runnables) + dts: genWmillTs(runnables) }, '*' ) @@ -129,7 +128,7 @@ let darkMode: boolean = $state(false) run(() => { - $runnables && files && saveFrontendDraft() + runnables && files && saveFrontendDraft() }) run(() => { iframe?.addEventListener('load', () => { @@ -140,7 +139,7 @@ iframe && iframeLoaded && initFiles && populateFiles() }) run(() => { - iframe && iframeLoaded && $runnables && populateRunnables() + iframe && iframeLoaded && runnables && populateRunnables() }) @@ -153,7 +152,7 @@ {iframe} bind:jobs bind:jobsById - runnables={$runnables} + {runnables} {path} />
diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 2954af40c8..60027a2a12 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -1,22 +1,10 @@ @@ -676,45 +638,6 @@ {#if appPath == ''} closeDraftDrawer()}> - - Choose a path to save the initial draft of the app. - -

Summary

-
- - { - if (appPath == '' && summary?.length > 0 && !dirtyPath) { - path?.setName( - summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - }} - /> -
-
- -
- {#snippet actions()}
{/snippet} +
{/if} closeSaveDrawer()}> - {#if !onLatest} - - By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff. - -
- {/if} - Summary -
- - { - if (appPath == '' && summary?.length > 0 && !dirtyPath) { - path?.setName( - summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - }} - /> -
-
- Deployment message -
- - -
-
- Path - - {#snippet actions()}
{/snippet} -
- {#if appPath == ''} - - Save this app once before you can publish it - - {:else} - - A viewer of the app will execute the runnables of the app on behalf of the publisher (you) - - It ensures that all required resources/runnable visible for publisher but not for viewer - at time of creating the app would prevent the execution of the app. To guarantee tight - security, a policy is computed at time of deployment of the app which only allow the - scripts/flows referred to in the app to be called on behalf of. Furthermore, static - parameters are not overridable. Hence, users will only be able to use the app as intended - by the publisher without risk for leaking resources not used in the app. - - -
- -

Public URL

-
- -
- { - policy.execution_mode = e.detail ? 'anonymous' : 'publisher' - setPublishState() - }} - /> -
- -
-
-
Public URL
-
- {#if secretUrl} - {@const href = `${window.location.origin}${base}/public/${$workspaceStore}/${secretUrl}`} - - {:else} - {/if} -
- Share this url directly or embed it using an iframe (if requiring login, top-level domain - of embedding app must be the same as the one of Windmill) -
- -
- {#if !$enterpriseLicense} - - Custom path is an enterprise only feature. - -
- {:else if !($userStore?.is_admin || $userStore?.is_super_admin)} - - Custom path can only be set by workspace admins - -
- {/if} - { - customPath = detail ? '' : undefined - }} - checked={customPath !== undefined} - options={{ - right: 'Use a custom URL' - }} - disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)} - /> - - {#if customPath !== undefined} -
-
Custom path
-
- { - dirtyCustomPath = true - }} - /> -
-
Custom public URL
-
- - -
{dirtyCustomPath ? customPathError : ''} -
- {/if} -
-
- - You will still need to deploy the app to make visible the latest changes - - - Embed this app in your own product to be used by your own users - {/if} +
@@ -988,7 +765,7 @@
- + {/snippet}