diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index d948fee088..2c29721ef6 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -1099,7 +1099,7 @@ pub async fn run_agent( // For non-Anthropic providers, response_format is handled by the query builder } - let user_wants_streaming = args.streaming.unwrap_or(false); + let user_wants_streaming = streaming_requested(args.streaming); *has_stream = user_wants_streaming && is_text_output; let mut final_events_str = String::new(); @@ -1701,6 +1701,17 @@ pub async fn run_agent( })) } +/// Whether the step asked for its answer as it is generated. Absence means on, matching the +/// schema's own default: a step that never wrote the key never had an opinion, and an answer +/// arriving as it is written is what people expect. Only an explicit `false` holds it back. +/// +/// The chat surfaces decide whether to open a stream from their own reading of the same config, +/// and a surface that opens one for an answer sent in a single piece re-runs the flow when the +/// connection times out. So this default is half of a contract, not a local preference. +fn streaming_requested(streaming: Option) -> bool { + streaming.unwrap_or(true) +} + #[cfg(test)] mod tests { use super::*; @@ -1713,6 +1724,13 @@ mod tests { } } + #[test] + fn an_unwritten_streaming_field_streams() { + assert!(streaming_requested(None)); + assert!(streaming_requested(Some(true))); + assert!(!streaming_requested(Some(false))); + } + /// Over 64 characters OpenAI rejects the key outright, which costs a wasted round /// trip per run and silently leaves that step with no prompt caching at all. #[test] diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 259d9c1cf9..0d76b1a0b8 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -35,6 +35,8 @@ export interface SchemaProperty { } min?: number max?: number + /** Height a string field's text area opens at, in rows. */ + minRows?: number currency?: string currencyLocale?: string multiselect?: boolean diff --git a/frontend/src/lib/components/AIProviderPicker.svelte b/frontend/src/lib/components/AIProviderPicker.svelte index 4392a2d513..19b1346b84 100644 --- a/frontend/src/lib/components/AIProviderPicker.svelte +++ b/frontend/src/lib/components/AIProviderPicker.svelte @@ -4,11 +4,7 @@ import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib' import type { AIProvider, ProviderConfig } from '$lib/gen' import { workspaceStore } from '$lib/stores' - import { get } from 'svelte/store' - import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import ResourcePicker from './ResourcePicker.svelte' - import ToggleButtonMore from './common/toggleButton-v2/ToggleButtonMore.svelte' import Toggle from './Toggle.svelte' import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage' import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte' @@ -17,9 +13,20 @@ value: ProviderConfig | undefined disabled?: boolean actions?: Snippet + /** The workspace the surface operates on, which a session or fork editor sets to something + * other than the one being navigated. Resources and the models read off them are per + * workspace, so without it this offers what the wrong one holds. */ + workspace?: string | undefined } - let { value: _uncheckedValue = $bindable(), disabled = false, actions }: Props = $props() + let { + value: _uncheckedValue = $bindable(), + disabled = false, + actions, + workspace = undefined + }: Props = $props() + + let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '') let value = $derived.by(() => { if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined @@ -30,7 +37,13 @@ let availableModels = $state([]) let filterText = $state('') - let modelsCache = new Map() + // Keyed by provider *and* path: two `customai` resources point at different base URLs, so they + // do not share a model list. + let modelsCache = new Map() + + // The resource picker offers every provider type at once and the pick is what names the kind. + // One string for the component's life: it is what the picker queries with. + const providerResourceTypes = Object.keys(AI_PROVIDERS).join(',') if (!_uncheckedValue) { _uncheckedValue = { @@ -57,12 +70,6 @@ return r }) - // Provider options for the toggle button group - const providerOptions = Object.entries(AI_PROVIDERS).map(([key, details]) => ({ - value: key as AIProvider, - label: details.label - })) - async function loadModels(signal?: AbortSignal) { const provider = value?.kind const resourceValue = value?.resource @@ -73,20 +80,20 @@ } loading = true - if (modelsCache.has(provider)) { - availableModels = modelsCache.get(provider) || [] + const cacheKey = `${effectiveWorkspace}:${provider}:${resourcePath}` + if (modelsCache.has(cacheKey)) { + availableModels = modelsCache.get(cacheKey) || [] loading = false return } try { - const workspace = get(workspaceStore) || '' - const models = await fetchAvailableModels(resourcePath, workspace, provider, signal) + const models = await fetchAvailableModels(resourcePath, effectiveWorkspace, provider, signal) if (signal?.aborted) { return } availableModels = models - modelsCache.set(provider, models) + modelsCache.set(cacheKey, models) } catch (e) { if (signal?.aborted) { return @@ -101,15 +108,24 @@ } } - // Handle provider selection - function onProviderChange(selectedProvider: AIProvider) { - if (value) { - value.kind = selectedProvider - value.resource = '' - value.model = '' - // Reasoning effort is model-specific; reset it with the model. - value.reasoning_effort = undefined + /** + * The provider kind follows the resource that was picked. Driven by the pick rather than by an + * effect on the picker's `valueType`, which also resolves for the value the field was opened on + * and would rewrite a saved config just for being looked at. + */ + function onResourcePicked(_path: string | undefined, type: string | undefined) { + // An empty type is the placeholder the picker keeps for a saved path it could not find. It + // says nothing about the provider, so the kind stands. + if (!value || !type || !(type in AI_PROVIDERS)) { + return } + if (value.kind === type) { + return + } + value.kind = type as AIProvider + // Models are per provider, and a reasoning token is per model. + value.model = '' + value.reasoning_effort = undefined } // Helper functions to handle $res: prefix like ObjectResourceInput does @@ -165,97 +181,74 @@ }) -
- - - {#snippet children({ item })} - {#each providerOptions.slice(0, 3) as option} - - {/each} - p.value === value?.kind) >= 3 ? '' : 'More'} - togglableItems={providerOptions.slice(3)} - {item} - bind:selected={() => value?.kind, (v) => v && onProviderChange(v)} - /> - {/snippet} - - - -
-
-

resource

- resourceValueToPath(value?.resource), - (v) => { - if (value) { - value.resource = pathToResourceValue(v) ?? '' - } +
+
+ Resource + + resourceValueToPath(value?.resource), + (v) => { + if (value) { + value.resource = pathToResourceValue(v) ?? '' } } - resourceType={value?.kind} - disabled={disabled || !value?.kind} - placeholder="Select resource" - selectFirst={true} - /> -
+ } + resourceType={providerResourceTypes} + {disabled} + {workspace} + placeholder="Select an AI provider resource" + selectFirst={false} + onValueChange={onResourcePicked} + /> +
- +
+ Model + value?.model, (v) => value && (value.model = v ?? '')} - placeholder="Select model" - disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)} - onCreateItem={(r) => { - availableModels.push(r) - if (value) value.model = r - }} - createText="Press enter to use custom model" - {loading} - clearable={false} - noItemsMsg={'No models available'} - bind:filterText + Reasoning effort + value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)} + providerConfig={value} + {disabled} />
+ {/if} - - {#if value?.model} -
-

reasoning effort

- value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)} - providerConfig={value} - {disabled} - /> -
- {/if} - - -
- { - if (!e.detail) { - removeConfig() - } else { - saveConfig(value) - } - }} - /> -
+
+ { + if (!e.detail) { + removeConfig() + } else { + saveConfig(value) + } + }} + />
{@render actions?.()} diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 8550a58b57..7f50794a52 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -1447,7 +1447,7 @@ {showSchemaExplorer} /> {:else if inputCat == 'ai-provider'} - + {:else if inputCat == 'email'}
+ + true} /> {/if} diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index d895cb4e2e..bfda6d6f18 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -44,6 +44,7 @@ import FlowRestartButton from './FlowRestartButton.svelte' import { useNestedRestartState } from './useNestedRestartState.svelte' import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording' + import { agentStreamingEnabled } from './flows/agentFormFields' interface Props { previewMode: 'upTo' | 'whole' @@ -163,11 +164,8 @@ let shouldUseStreaming = $derived.by(() => { const modules = flowStore.val.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 - ) + if (lastModule?.value?.type !== 'aiagent') return false + return agentStreamingEnabled(lastModule.value) }) function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow { diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index d766c06603..c5ae65eda2 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -53,6 +53,13 @@ label?: string /** Replaces the label header, so a setting's own toggle can name the field. */ header?: Snippet + /** Renders after the label: a button to unset the field, a badge. */ + labelExtra?: Snippet + /** Drop the schema's description paragraph, for a form that carries it in a tooltip. */ + hideDescription?: boolean + /** Keep the connect and transform controls out of the way until the row is reached, unless + * the field already holds something the controls are needed to read. */ + subtleControls?: boolean /** The kind this field always holds, for a value that doesn't carry a `type` of its * own — a flow predicate is stored as a bare `{ expr }`. */ argType?: InputTransform['type'] @@ -76,6 +83,14 @@ /** Hide the static/expression switch, for a field that only ever holds one kind. * The connect button and the AI helper stay. */ noDynamicToggle?: boolean + /** Hide the connect button, for a surface with nothing to connect to. Distinct from + * `noDynamicToggle`, which a field forced to an expression also sets. */ + noConnect?: boolean + /** Drop the expression option, and every affordance that writes one: an expression reaching + * such a field is stored and deployed like any other, whichever control put it there. The + * rest of the switch stays, so a field can still be AI-filled or static. A field already + * holding an expression keeps the option, or it could not be switched off it. */ + noJavascript?: boolean /** Replaces the default StepInputGen, for a field with its own AI helper. That * helper drives `suggestion` (its ghost text) and `aiOnKeyUp` (Tab to accept), * which the built-in one reaches through `stepInputGen` instead. */ @@ -104,6 +119,9 @@ argName = $bindable(), label = undefined, header = undefined, + labelExtra = undefined, + hideDescription = false, + subtleControls = false, argType = undefined, collapsed = false, animateAppear = false, @@ -118,6 +136,8 @@ variableEditor = undefined, itemPicker = undefined, noDynamicToggle = false, + noConnect = false, + noJavascript = false, aiGen = undefined, suggestion = $bindable(), focused = $bindable(), @@ -183,6 +203,11 @@ allowedAiTransforms === undefined || allowedAiTransforms.includes(argName) ) + // A `${}` field is static text that interpolates JavaScript, so it is only on offer where + // expressions are. Elsewhere the same field is plain static: labelled `static`, edited in the + // ordinary input, with no `${...}` hint promising an escape hatch that isn't there. + let staticTemplateOffered = $derived(isStaticTemplate(inputCat) && !noJavascript) + // `argType` wins over whatever the value carries: a predicate has no `type` field, so // inferring would land it on the static input instead of the expression editor. const argKind = $derived(argType ?? arg?.type) @@ -250,7 +275,11 @@ return } - if (isCodeInjection(rawValue)) { + // `${...}` becomes a JavaScript transform, so it is only read as one where such a transform + // can be stored — the same condition `staticTemplateOffered` renders under. Elsewhere the + // text stays what was typed, rather than turning into code the store then drops or, worse, + // keeps pointing at a flow context this value will never be evaluated in. + if (isCodeInjection(rawValue) && !noJavascript) { arg.expr = getDefaultExpr( argName, previousModuleId, @@ -273,7 +302,12 @@ let codeInjectionDetected = $state(false) - function checkCodeInjection(rawValue: string) { + // A static value is whatever JSON the field holds, so it need not be a string, and the caller + // runs inside an effect: throwing here would take the whole form down rather than one field. + function checkCodeInjection(rawValue: unknown): { word: string; value: string }[] | undefined { + if (typeof rawValue !== 'string') { + return undefined + } if (!arg || !rawValue || rawValue.length < 3 || !dynamicTemplateRegexPairs) { return undefined } @@ -307,6 +341,7 @@ isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle && + !noJavascript && codeInjectionDetected ) { setJavaScriptExpr(arg.value) @@ -561,8 +596,16 @@ untrack(() => handleFieldVisibility(schema, arg, otherArgs)) }) let connecting = $derived($propPickerConfig?.propName == argName) + let fieldDescription = $derived( + hideDescription ? undefined : schema?.properties?.[argName]?.description + ) + // Fading the controls away is only safe while the row itself says what it holds. An expression + // or an AI-filled value is only legible from the toggle, so those keep it on screen. + let controlsPinned = $derived(connecting || propertyType !== 'static' || Boolean(suggestion)) + // Its picker builds an expression, so it goes with the expression option. let shouldShowS3ArrayHelper = $derived( inputCat === 'list' && + !noJavascript && ['s3object', 's3_object'].includes(schema?.properties?.[argName]?.items?.resourceType) ) @@ -600,7 +643,9 @@ type={schema.properties?.[argName]?.type} /> - {#if isStaticTemplate(inputCat)} + {@render labelExtra?.()} + + {#if staticTemplateOffered}
{#if aiGen} {@render aiGen()} - {:else if enableAi} + {:else if enableAi && !noJavascript} {/if} - {#if propPickerWrapperContext} + {#if propPickerWrapperContext && !noConnect} {#snippet children({ item })} {#if fieldAllowsAi} + {/if} - {#if isStaticTemplate(inputCat)} + {#if staticTemplateOffered} {/if} - {#if codeInjectionDetected && propertyType == 'static'} + {#if noJavascript && propertyType !== 'javascript'} + + {:else if codeInjectionDetected && propertyType == 'static'}
- {#if argName && schema?.properties?.[argName]?.description} + {#if fieldDescription}
-										{schema.properties[argName].description}
+										{fieldDescription}
 									
{/if} - {:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle} + {:else if staticTemplateOffered && propertyType == 'static' && !noDynamicToggle}
- {#if argName && schema?.properties?.[argName]?.description} + {#if fieldDescription}
-										{schema.properties[argName].description}
+										{fieldDescription}
 										
{/if} @@ -868,6 +935,8 @@ { focused = false @@ -898,7 +967,13 @@ }} label={argName} bind:editor={monaco} - bind:description={schema.properties[argName].description} + bind:description={ + () => fieldDescription, + (v) => { + const property = schema.properties?.[argName] + if (!hideDescription && property) property.description = v + } + } bind:value={arg.value} type={schema.properties[argName].type} oneOf={schema.properties[argName].oneOf} @@ -995,11 +1070,9 @@ /> {/if} - {#if argName && schema?.properties?.[argName]?.description} + {#if fieldDescription}
-
{schema.properties[argName].description}
+
{fieldDescription}
{/if} diff --git a/frontend/src/lib/components/InputTransformPickers.svelte b/frontend/src/lib/components/InputTransformPickers.svelte new file mode 100644 index 0000000000..199039fb67 --- /dev/null +++ b/frontend/src/lib/components/InputTransformPickers.svelte @@ -0,0 +1,68 @@ + + + { + if (pickForField) { + args[pickForField].value = '$var:' + path + } + }} + itemName="Variable" + extraField="path" + loadItems={async () => + (await VariableService.listVariable({ workspace: ws ?? '' })).map((x) => ({ + name: x.path, + ...x + }))} +> + {#snippet submission()} +
+ +
+ {/snippet} +
+ + diff --git a/frontend/src/lib/components/InputTransformSchemaForm.svelte b/frontend/src/lib/components/InputTransformSchemaForm.svelte index d0d29b3111..465e173f1f 100644 --- a/frontend/src/lib/components/InputTransformSchemaForm.svelte +++ b/frontend/src/lib/components/InputTransformSchemaForm.svelte @@ -1,17 +1,16 @@
- {#if enableAi} + + {#if enableAi && !isAgentTool}
- { - if (pickForField) { - args[pickForField].value = '$var:' + path - } - }} - itemName="Variable" - extraField="path" - loadItems={async () => - (await VariableService.listVariable({ workspace: ws ?? '' })).map((x) => ({ - name: x.path, - ...x - }))} -> - {#snippet submission()} -
- -
- {/snippet} -
- - + diff --git a/frontend/src/lib/components/ModulePreviewForm.svelte b/frontend/src/lib/components/ModulePreviewForm.svelte index 88b99a05c5..72bb9fe36b 100644 --- a/frontend/src/lib/components/ModulePreviewForm.svelte +++ b/frontend/src/lib/components/ModulePreviewForm.svelte @@ -14,6 +14,7 @@ import { getResourceTypes } from './resourceTypesStore' import { twMerge } from 'tailwind-merge' import { workspaceStore } from '$lib/stores' + import { AGENT_FIELDS, initialVisibleAgentFields } from './flows/agentFormFields' interface Props { schema: Schema | { properties?: Record; required?: string[] } @@ -43,15 +44,45 @@ isValid = allTrue(inputCheck) ?? false }) + /** An agent asks for the same fields here that its own form shows: a setting the step leaves + * unset is not something a run needs told, and listing all eleven buries the message under the + * configuration. What the step configures stays, as it does on any other step. A schema key the + * field registry doesn't know is kept, so a new one is never silently dropped. A run input is + * kept whatever the step holds: this form has no add-field control, so hiding one would leave + * no way at all to supply it. */ + let schemaKeys = $derived(Object.keys(schema?.properties ?? {})) + + let visibleKeys = $derived.by(() => { + const all = schemaKeys + if ((mod.value as { type?: string })?.type !== 'aiagent') return all + const transforms = (mod.value as { input_transforms?: Record }) + ?.input_transforms + const visible = initialVisibleAgentFields(transforms, schema?.properties) + const known = new Set(AGENT_FIELDS.filter((f) => !f.runInput).map((f) => f.key)) + return all.filter((key) => !known.has(key) || visible.has(key)) + }) + let keys: string[] = $state([]) $effect(() => { - let lkeys = Object.keys(schema?.properties ?? {}) + let lkeys = visibleKeys if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) { keys = lkeys - untrack(() => stepsInputArgs?.removeExtraKey(mod.id, keys)) + // Pruned against the schema rather than against what is shown. What a run was given for a + // field lives only here, so dropping it when the field merely stops being displayed would + // discard it: an agent hides the settings its step leaves unset, and clearing one in the + // Inputs tab hides it. + untrack(() => stepsInputArgs?.removeExtraKey(mod.id, schemaKeys)) } }) + /** Whether re-evaluating has anything to restore. A field the step configures nothing for + * evaluates to blank, so the control would only clear what was typed to run with. */ + function hasConfiguredInput(argName: string): boolean { + const transform = (mod.value as any)?.input_transforms?.[argName] + if (!transform) return false + return transform.type === 'javascript' ? !!transform.expr : transform.value !== undefined + } + function plugIt(argName: string) { stepsInputArgs?.setEvaluatedStepArg( mod.id, @@ -158,7 +189,7 @@ workspace={opWs} > {#snippet fieldHeaderActions()} - {#if stepsInputArgs?.isArgManuallySet(mod.id, argName)} + {#if stepsInputArgs?.isArgManuallySet(mod.id, argName) && hasConfiguredInput(argName)}