From 189793c2e4db7f1c853695ebcc895c1ec82ed19f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 17 Sep 2026 09:59:05 +0200 Subject: [PATCH] feat: flow chat model picker on a shared model-settings component (#11187) * refactor: render the session chat model menu from a shared ChatModelSettings config Co-Authored-By: Claude Fable 5.1 * feat: pick the flow chat's model and thinking from the provider fields the flow exposes Co-Authored-By: Claude Fable 5.1 * fix: name only the thinking level the flow run will send on the model button Co-Authored-By: Claude Opus 5 (1M context) * fix: let the flow chat take a typed model id and keep a shared thinking input editable Co-Authored-By: Claude Opus 5 (1M context) * fix: promote a flow input to the model button only where its control can edit it Co-Authored-By: Claude Opus 5 (1M context) * fix: drop any reasoning token the chosen model rejects before a flow chat run Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5.1 --- cli/src/guidance/skills.gen.ts | 25 +- .../components/AIReasoningEffortPicker.svelte | 2 +- .../lib/components/FlowPreviewContent.svelte | 1 + .../copilot/ChatModelSettings.svelte | 314 +++++++++++ .../copilot/ReasoningEffortSlider.svelte | 172 ++++++ .../copilot/chat/AIChatModelSettings.svelte | 316 +++-------- .../copilot/chatModelSettings.test.ts | 246 +++++++++ .../components/copilot/chatModelSettings.ts | 219 ++++++++ .../copilot/reasoningRegistry.test.ts | 26 +- .../components/copilot/reasoningRegistry.ts | 33 +- .../components/flows/content/FlowInput.svelte | 1 + .../flows/conversations/FlowChat.svelte | 5 + .../conversations/FlowChatInterface.svelte | 141 ++++- .../FlowChatModelSettings.svelte | 299 +++++++++++ .../conversations/agentChatInputs.test.ts | 492 ++++++++++++++++++ .../flows/conversations/agentChatInputs.ts | 441 ++++++++++++++++ .../(logged)/flows/get/[...path]/+page.svelte | 1 + system_prompts/auto-generated/flow.md | 25 +- system_prompts/auto-generated/prompts.ts | 25 +- .../auto-generated/skills/write-flow/SKILL.md | 25 +- system_prompts/base/flow-base.md | 25 +- 21 files changed, 2510 insertions(+), 324 deletions(-) create mode 100644 frontend/src/lib/components/copilot/ChatModelSettings.svelte create mode 100644 frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte create mode 100644 frontend/src/lib/components/copilot/chatModelSettings.test.ts create mode 100644 frontend/src/lib/components/copilot/chatModelSettings.ts create mode 100644 frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte create mode 100644 frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts create mode 100644 frontend/src/lib/components/flows/conversations/agentChatInputs.ts diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index e4490276ce..993f546d3a 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5276,15 +5276,26 @@ tool, \`websearch\` for web search. } \`\`\` -- \`provider\` is a static object, not a bare resource string: \`{ "kind": , +- \`provider\` is an object, not a bare resource string: \`{ "kind": , "resource": "$res:", "model": }\`. Required unless the module links to a saved - agent through \`value.agent\` + agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its + fields to flow inputs instead — see below ### Chat-Mode Flows A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer sends one message per turn and renders the conversation. It needs a required \`user_message\` string -input, read by the agent. Any other flow input stays and is asked for under Configure inputs. +input, read by the agent. Any other flow input the composer does not edit itself is asked for +under Configure inputs. + +**A static \`provider\` gives a chat that cannot change its model.** Feed it from flow inputs +instead, either way round: one input carrying the whole object (\`"expr": "flow_input.model_config"\`) +makes every field editable, or wire it field by field to fix some and expose others. A field the +chat can write becomes a control in the composer — a provider picker, a model list, a thinking +control — and a field left static is fixed, with no control drawn for it. \`kind\` is the one +exception: the composer writes it only together with \`resource\`, since a provider is picked as a +pair, so a \`kind\` input wired on its own stays askable under Configure inputs and nothing the run +needs becomes unreachable. \`\`\`json { @@ -5293,8 +5304,8 @@ input, read by the agent. Any other flow input stays and is asked for under Conf "type": "aiagent", "input_transforms": { "provider": { - "type": "static", - "value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" } + "type": "javascript", + "expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })" }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, @@ -5307,6 +5318,10 @@ input, read by the agent. Any other flow input stays and is asked for under Conf } \`\`\` +- Wiring field by field means one object literal whose values are literals or bare \`flow_input.x\` + references. A spread, a call or a computed key leaves the composer unable to tell which input + feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object + is read instead as that one input carrying every field - \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing - \`streaming\` on makes the answer and its thinking appear token by token instead of all at once - \`user_attachments\` points at a flow input typed as an array of s3 objects diff --git a/frontend/src/lib/components/AIReasoningEffortPicker.svelte b/frontend/src/lib/components/AIReasoningEffortPicker.svelte index 3ebf94c9bf..39b4e80011 100644 --- a/frontend/src/lib/components/AIReasoningEffortPicker.svelte +++ b/frontend/src/lib/components/AIReasoningEffortPicker.svelte @@ -27,7 +27,7 @@ let capability = $derived( provider && model ? getReasoningCapability(provider, model) - : { supported: false, levels: [], canDisable: false } + : { supported: false, levels: [], canDisable: false, known: false } ) // The token that turns reasoning off on a model that reasons by default diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index f6f6da0cc9..cd2460b1b0 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -473,6 +473,7 @@ hideSidebar={true} path={$pathStore} inputSchema={flowStore.val.schema} + flowModules={flowStore.val.value?.modules} /> {:else} diff --git a/frontend/src/lib/components/copilot/ChatModelSettings.svelte b/frontend/src/lib/components/copilot/ChatModelSettings.svelte new file mode 100644 index 0000000000..a1e677d46b --- /dev/null +++ b/frontend/src/lib/components/copilot/ChatModelSettings.svelte @@ -0,0 +1,314 @@ + + +{#snippet trigger()} +
+ +
+{/snippet} + +{#snippet typedField( + value: string, + placeholder: string, + onCommit: (value: string) => void, + close: () => void +)} + {#key value} + onCommit(e.currentTarget.value.trim()), + // Capture, not bubble: Svelte delegates `keydown` to the root, which sits above the + // menu — so a bubble handler here would run only after melt's own listener had read + // the key as typeahead and moved focus. A capture key is not delegatable, so this + // becomes a real listener on the input and sees the event first. + onkeydowncapture: (e) => { + // Escape cancels: let it reach the menu with the value untouched. + if (e.key === 'Escape') return + // Tab closes the menu, unmounting this field before focus moves, so no change + // event would ever fire. Commit on the way past. + if (e.key === 'Tab') { + onCommit(e.currentTarget.value.trim()) + return + } + // Enter means done: commit and close, rather than leaving the menu open around a + // field the commit is about to rebuild. + if (e.key === 'Enter') { + e.preventDefault() + onCommit(e.currentTarget.value.trim()) + close() + return + } + // Everything else is typing; the menu reads loose keys as typeahead. + e.stopPropagation() + } + }} + /> + {/key} +{/snippet} + +{#snippet section(sec: ChoiceSection, item: MeltItem, close: () => void)} +
{sec.label}
+ {#if sec.loading} +
+ Loading... +
+ {:else if sec.options.length === 0} +
{sec.emptyMessage ?? 'Nothing to choose from'}
+ {:else} +
+ {#each sec.options as option (option.key)} + option.onSelect()}> + {option.label} + {#if option.hint} + {option.hint} + {/if} + {#if option.selected} + + {/if} + + {/each} +
+ {/if} + {#if sec.custom && !sec.loading} + {@const custom = sec.custom} +
+ {@render typedField( + '', + custom.placeholder, + (value) => { + if (value) custom.onCommit(value) + }, + close + )} +
+ {/if} +{/snippet} + +{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)} + {#each items.filter((row) => !row.hide) as row (row.displayName)} + {#if row.separatorTop} +
+ {/if} + {#if row.submenuItems} + + + {:else} + row.action?.(e)}> + {#if row.icon} + + {/if} + {row.displayName} + {#if row.selected} + + {/if} + + {/if} + {/each} +{/snippet} + +{#if config.readOnly} + {@render trigger()} +{:else} + + {#snippet buttonReplacement()} + {@render trigger()} + {/snippet} + {#snippet menu({ item, builders, close })} +
+ {#if config.topItems} +
+ {@render rows(config.topItems(close), item, builders)} +
+ {/if} + {#each config.sections ?? [] as sec (sec.label)} +
+ {@render section(sec, item, close)} +
+ {/each} + {#if reasoning} +
+ {#if controlState === 'fixed'} + {}} + unsupportedReason={fixedReason} + /> + {:else if controlState === 'awaiting-model'} + {}} + unsupportedReason="Pick a model first" + /> + {:else if controlState === 'unknown'} + +
+
Thinking
+ {@render typedField(reasoning.value ?? '', 'none', reasoning.onSelect, close)} +
+ Windmill has no thinking levels for this provider — type what it accepts. +
+
+ {:else if controlState === 'ladder'} + + effortSlider?.adjust(e)} + class="block group" + > + (stop === reasoning?.offToken ? 'off' : stop)} + overrideLabel={stops.includes(currentStop) ? undefined : effortLabel} + /> + + {:else} + + {}} + unsupportedReason="Not supported by this model" + /> + {/if} +
+ {/if} + {#if config.bottomItems} +
+ {@render rows(config.bottomItems(close), item, builders)} +
+ {/if} +
+ {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte b/frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte new file mode 100644 index 0000000000..24ea4cf8aa --- /dev/null +++ b/frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte @@ -0,0 +1,172 @@ + + +{#if unsupportedReason} + +
+
Thinking
+
{unsupportedReason}
+
+{:else} +
+ Thinking + {overrideLabel ?? format(current)} +
+ {#if stops.length > 1} + +
+ onSelect(stops[+e.currentTarget.value])} + onclick={(e) => { + // `click`, not `pointerup`: it is the event that means pressed and released on + // the track, so a press that began on the row above cannot commit an effort + // nobody chose. Only the click that moved nothing — any other stop has already + // committed through `oninput`, and doing it again would write it twice. + if (!hasPosition && +e.currentTarget.value === stopIndex) { + onSelect(stops[stopIndex]) + } + }} + use:isolatePointer + class="lean-range no-default-style w-full" + aria-label="Reasoning effort" + /> +
+ {/if} +{/if} + + diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index 9dd94fa4ea..914ad51bc9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -1,10 +1,12 @@ {#snippet externalLinkIcon()} {/snippet} - - {#snippet buttonReplacement()} -
- -
- {/snippet} - {#snippet menu({ item, builders, close })} -
- - {#if promptSettings} - - {/if} + -
-
Model
-
- {#each models as m (m.provider + m.model)} - selectModel(m)} - > - {m.model} - {#if m.model === providerModel.model && m.provider === providerModel.provider} - - {/if} - - {/each} -
- -
- {#if capability.supported} - - -
- Thinking - {currentStop} -
- {#if stops.length > 1} - -
- selectReasoning(stops[+e.currentTarget.value])} - use:isolatePointer - class="lean-range no-default-style w-full" - aria-label="Reasoning effort" - /> -
- {/if} -
- {:else} - -
-
Thinking
-
Not supported by this model
-
- {/if} - - - (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)} - > - Always expand thinking - {#if thinkingPreferences.expandByDefault} - - {/if} - -
- {/snippet} -
- - {#if promptSettings} {/if} - - diff --git a/frontend/src/lib/components/copilot/chatModelSettings.test.ts b/frontend/src/lib/components/copilot/chatModelSettings.test.ts new file mode 100644 index 0000000000..04d1834556 --- /dev/null +++ b/frontend/src/lib/components/copilot/chatModelSettings.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { + carriedReasoning, + fixedReasoningReason, + reasoningControlState, + reasoningDisplay, + REASONING_PROVIDER_DEFAULT, + type ChatModelSettingsReasoning +} from './chatModelSettings' +import { + getReasoningCapability, + REASONING_OFF, + resolveEffectiveReasoning +} from './reasoningRegistry' + +/** + * The trigger's suffix and the slider's stops are read side by side, so they have to agree + * about one value — the provider-native off token must not read as `none` on one and `off` + * on the other, and an effort the run does not send must not be named at all. + */ +function display( + reasoning: Partial & { provider: any; model: string } +) { + const full = { + value: undefined, + offToken: undefined, + sendsDefaultWhenUnset: false, + onSelect: () => {}, + ...reasoning + } as ChatModelSettingsReasoning + const capability = getReasoningCapability(full.provider, full.model) + // Composed exactly as the component composes it, so the test exercises the real pair. + const effective = resolveEffectiveReasoning({ + provider: full.provider, + model: full.model, + reasoning: full.value + }) + return reasoningDisplay(full, capability, effective) +} + +describe('reasoningDisplay', () => { + it('says nothing for a model that cannot reason', () => { + const shown = display({ provider: 'openai', model: 'gpt-4o' }) + expect(shown.label).toBeUndefined() + expect(shown.stops).toEqual([]) + }) + + // The session chat's own sentinel: what it stores is already the word the reader sees. + it('reads the session chat off sentinel as off', () => { + const shown = display({ + provider: 'openai', + model: 'gpt-5.1', + offToken: REASONING_OFF, + value: REASONING_OFF, + sendsDefaultWhenUnset: true + }) + expect(shown.label).toBe(REASONING_OFF) + expect(shown.currentStop).toBe(REASONING_OFF) + }) + + // An agent writes the provider's own token, which can read as anything. + it('reads a provider-native off token as off too', () => { + const shown = display({ + provider: 'openai', + model: 'gpt-5.1', + offToken: 'none', + value: 'none' + }) + expect(shown.label).toBe(REASONING_OFF) + expect(shown.currentStop).toBe('none') + expect(shown.stops[0]).toBe('none') + }) + + it('names the level a chat that fills one in will send', () => { + const shown = display({ + provider: 'openai', + model: 'gpt-5.1', + offToken: REASONING_OFF, + value: undefined, + sendsDefaultWhenUnset: true + }) + expect(shown.label).toBe('high') + }) + + // An agent step omits the field, so naming a level would claim something untrue. + it('names no level where an unset effort is simply not sent', () => { + const shown = display({ + provider: 'anthropic', + model: 'claude-sonnet-5', + offToken: 'none', + value: undefined + }) + expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT) + expect(shown.currentStop).toBe('') + }) + + // Claude 4.x only thinks when asked, so an absent effort is already off — and the flow + // chat must be able to get back to it after a level has been picked. + it('offers omission as the off stop where that is how the model disables', () => { + const unset = display({ provider: 'anthropic', model: 'claude-opus-4-6', offToken: '' }) + expect(unset.label).toBe(REASONING_OFF) + expect(unset.stops[0]).toBe('') + const picked = display({ + provider: 'anthropic', + model: 'claude-opus-4-6', + offToken: '', + value: 'high' + }) + expect(picked.currentStop).toBe('high') + expect(picked.stops).toContain('') + }) + + // gpt-5 reasons at medium with no effort sent, so an empty off token buys no off stop. + it('offers no off where the model cannot stop thinking', () => { + const shown = display({ provider: 'openai', model: 'gpt-5', offToken: '' }) + expect(shown.stops).not.toContain('') + expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT) + }) + + // The run sends an explicitly set effort whatever the model, so a token typed against a + // provider we have no rules for has to reach the trigger — silence would hide it. + it('names a set effort even where it can offer no ladder', () => { + const shown = display({ provider: 'customai', model: 'deepseek-r1', value: 'high' }) + expect(shown.label).toBe('high') + expect(shown.stops).toEqual([]) + }) +}) + +describe('carriedReasoning', () => { + const cap = (model: string) => getReasoningCapability('openai', model) + + // A level carried onto a model that cannot think stays in the flow input, and the run sends it. + it('drops a level the new model does not have', () => { + expect(carriedReasoning('high', '', cap('gpt-4o'))).toBeUndefined() + expect(carriedReasoning('xhigh', '', cap('gpt-5.1'))).toBeUndefined() + }) + + it('keeps a level the new model does have', () => { + expect(carriedReasoning('high', '', cap('gpt-5.1'))).toBe('high') + }) + + it('carries off only onto a model that can truly stop thinking', () => { + expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5.1'))).toBe(REASONING_OFF) + expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5'))).toBeUndefined() + }) + + // A provider the registry has no rules for draws no thinking control, so a carried level + // would be invisible and unclearable — and still sent, since an explicitly set effort + // goes out whatever the model. + it('drops the effort where it has no rules for the provider', () => { + expect( + carriedReasoning('high', REASONING_OFF, getReasoningCapability('customai', 'deepseek-r1')) + ).toBeUndefined() + }) + + it('has nothing to carry when no effort is set', () => { + expect(carriedReasoning(undefined, '', cap('gpt-5.1'))).toBeUndefined() + expect(carriedReasoning('', '', cap('gpt-5.1'))).toBeUndefined() + }) +}) + +const asReasoning = (over: Partial): ChatModelSettingsReasoning => + ({ + provider: 'openai', + model: 'gpt-5.1', + value: undefined, + offToken: REASONING_OFF, + sendsDefaultWhenUnset: false, + writable: true, + typedWhenUnknown: true, + onSelect: () => {}, + ...over + }) as ChatModelSettingsReasoning + +/** The control is always drawn; this is the only thing that decides what it draws. */ +describe('reasoningControlState', () => { + const cap = (model: string) => getReasoningCapability('openai', model) + + it('shows the ladder for a model with levels', () => { + expect(reasoningControlState(asReasoning({}), cap('gpt-5.1'))).toBe('ladder') + }) + + it('says a model cannot think when the registry knows it cannot', () => { + expect(reasoningControlState(asReasoning({ model: 'gpt-4o' }), cap('gpt-4o'))).toBe( + 'unsupported' + ) + }) + + // Not the same as "cannot think": we have no rules for the provider, so the flow's own + // token is typed rather than picked. + it('asks for a typed token where it has no rules for the provider', () => { + expect( + reasoningControlState( + asReasoning({ provider: 'customai', model: 'deepseek-r1' }), + getReasoningCapability('customai', 'deepseek-r1') + ) + ).toBe('unknown') + }) + + // The session chat has no typed effort: a provider with no rules reads as unable to think. + it('offers no typed token to a chat that does not take one', () => { + expect( + reasoningControlState( + asReasoning({ provider: 'customai', model: 'deepseek-r1', typedWhenUnknown: false }), + getReasoningCapability('customai', 'deepseek-r1') + ) + ).toBe('unsupported') + }) + + // A provider with a full ladder must not be described as unreadable just because no + // model has been picked yet — which is the state right after choosing a resource. + it('waits for a model rather than blaming the provider', () => { + expect( + reasoningControlState(asReasoning({ model: undefined }), { supported: false, known: false }) + ).toBe('awaiting-model') + }) + + it('shows what the flow fixed when this chat cannot write it', () => { + expect(reasoningControlState(asReasoning({ writable: false }), cap('gpt-5.1'))).toBe('fixed') + }) +}) + +describe('fixedReasoningReason', () => { + it('names the level the run will use', () => { + expect( + fixedReasoningReason(asReasoning({ value: 'high' }), { supported: true, known: true }) + ).toBe('high · set in the flow') + }) + + // The step naming no effort at all is the common shape; saying it was "set in the flow" + // would describe a line the flow does not contain. + it('does not claim a level the step never set', () => { + expect( + fixedReasoningReason(asReasoning({ value: undefined }), { supported: true, known: true }) + ).toBe('Not set in the flow, so the provider decides') + }) + + it('surfaces a level fixed on a model that cannot use it', () => { + expect( + fixedReasoningReason(asReasoning({ value: 'high', model: 'gpt-4o' }), { + supported: false, + known: true + }) + ).toContain('cannot think') + }) +}) diff --git a/frontend/src/lib/components/copilot/chatModelSettings.ts b/frontend/src/lib/components/copilot/chatModelSettings.ts new file mode 100644 index 0000000000..3fce0270d1 --- /dev/null +++ b/frontend/src/lib/components/copilot/chatModelSettings.ts @@ -0,0 +1,219 @@ +import type { AIProvider } from '$lib/gen' +import type { Item } from '$lib/utils' +import { REASONING_OFF } from './reasoningRegistry' + +/** + * The contract between a chat and its model button. + * + * One component renders this menu for every chat — the copilot's own session chat and + * the flow chat — so the component knows only about rows, choices and a reasoning + * ladder. What a row means (a workspace AI resource, a prompt to edit, a reading + * preference) is the caller's business, and each caller derives its own config: a fixed + * one for the session chat, one derived from the flow's exposed inputs for flow chat. + */ + +export type ModelChoice = { + /** Stable across rebuilds of the config; used as the `{#each}` key. */ + key: string + label: string + /** Muted trailing text, e.g. the provider a resource speaks. */ + hint?: string + selected: boolean + onSelect: () => void +} + +export type ChoiceSection = { + /** Section heading, e.g. 'Provider' or 'Model'. */ + label: string + options: ModelChoice[] + /** Fetched lists render one consistent loading line instead of the options. */ + loading?: boolean + /** Shown when the list is empty and settled. */ + emptyMessage?: string + maxHeight?: string + /** + * A typed entry under the options, for a value the list does not hold: an endpoint with no + * listing, or a model newer than the catalogue. An empty entry commits nothing. + */ + custom?: { placeholder: string; onCommit: (value: string) => void } +} + +export type ChatModelSettingsConfig = { + /** The trigger's main text: the chosen model, or an invitation to choose one. */ + label: string + title?: string + /** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */ + badge?: { text: string; warn?: boolean } + /** Nothing here is editable — the trigger still names the model, but no menu opens. */ + readOnly?: boolean + readOnlyReason?: string + /** + * Rows above and below the choice sections. Given the menu's own `close` because a + * row that opens a modal must close the menu first, while a row that toggles a + * preference must not. + */ + topItems?: (close: () => void) => Item[] + sections?: ChoiceSection[] + bottomItems?: (close: () => void) => Item[] + /** + * The thinking slider. The ladder is derived from the provider and model here rather + * than by each caller, so a new provider's effort levels reach every chat at once. + * `value` is the raw stored effort (undefined meaning the model's default), and + * `offToken` the token this caller stores for "off" — the copilot keeps its own + * sentinel and translates when it calls the provider, an agent writes the + * provider-native token straight into its step. + */ + reasoning?: ChatModelSettingsReasoning +} + +export type ChatModelSettingsReasoning = { + /** Absent until the chat knows what it will run; the ladder then has nothing to stand on. */ + provider: AIProvider | undefined + model: string | undefined + value: string | undefined + offToken: string | undefined + /** + * What an unset value means on the wire. The copilot fills one in before calling the + * provider, so unset really runs at the default effort and the button says so. An agent + * step omits the field entirely, so unset means whatever the provider does by itself — + * naming a level there would state something the run does not do. + */ + sendsDefaultWhenUnset: boolean + /** + * Whether this chat can write the effort back. False where the flow fixes it in the step: + * the run still uses it, so the button shows it and refuses to pretend otherwise — only + * the flow editor can change it. + */ + writable: boolean + /** + * Whether a provider the registry has no rules for gets a typed effort field. True for an + * agent, which writes the token straight into its step. False for the copilot, which then + * shows the model as unable to think. + */ + typedWhenUnknown: boolean + onSelect: (token: string) => void +} + +/** What an unset agent effort reads as: the provider decides, and we do not know what. */ +export const REASONING_PROVIDER_DEFAULT = 'default' + +/** + * The effort to keep when the model changes, or nothing where the new model has no such + * level. Dropped rather than carried because a model that cannot think at that level either + * rejects the request or quietly runs at another one, and the button would name a level the + * run never used. Off survives only onto a model that can truly disable. + * + * A model the registry has no rules for drops it too, for the same reason: a chat without + * `typedWhenUnknown` draws no thinking control there, so a carried level would be invisible + * and unclearable while still going out on the wire — `resolveEffectiveReasoning` sends an explicitly set effort + * whatever the model, and a provider that rejects the field would then fail every turn with + * nothing on screen to explain it. + */ +export function carriedReasoning( + current: string | undefined, + offToken: string | undefined, + capability: { levels: string[]; canDisable: boolean } +): string | undefined { + if (current === undefined || current === '') return undefined + if (offToken !== undefined && current === offToken) { + return capability.canDisable ? current : undefined + } + return capability.levels.includes(current) ? current : undefined +} + +/** + * What the menu shows for the reasoning ladder: the stops the slider offers, the one it + * sits on, and the suffix on the trigger. + * + * Pure and here rather than in the component because these three have to agree — a stop + * the slider renders as `off` must not read as the provider's own `none` on the button — + * and because the rules are provider-shaped enough to be worth testing directly. + */ +export function reasoningDisplay( + reasoning: ChatModelSettingsReasoning | undefined, + capability: { supported: boolean; levels: string[]; canDisable: boolean }, + effective: string | undefined +): { + stops: string[] + currentStop: string + /** Trigger suffix, or undefined when there is nothing truthful to say. */ + label: string | undefined +} { + if (!reasoning) return { stops: [], currentStop: '', label: undefined } + if (!capability.supported) { + // No ladder to place it on, but an effort that is explicitly set still goes out — + // `resolveEffectiveReasoning` sends one whatever the model — so the button names it. + // Saying nothing would hide from the reader what the run is about to do. + const set = reasoning.value ? reasoning.value : undefined + return { stops: [], currentStop: '', label: set } + } + // An off position only where the model can truly disable, else the provider would + // coerce it to the lowest level; then the provider-native levels. + const offToken = capability.canDisable ? reasoning.offToken : undefined + const stops = [...(offToken !== undefined ? [offToken] : []), ...capability.levels] + // An agent whose model disables by omission stores the empty string, which the run + // treats as no effort at all — so an unset value already sits on that stop. + const isOff = offToken !== undefined && (reasoning.value ?? '') === offToken + if (isOff) { + // The off token is provider-native and can read as anything ('none', 'disabled'); + // on the button and on the slider it always reads as off. + return { stops, currentStop: offToken as string, label: REASONING_OFF } + } + if (reasoning.value === undefined || reasoning.value === '') { + // Where the provider takes an explicit disable, unset is a third state — the + // provider's own level, above off — that the ladder has no position for. The + // button still names it, and every stop the ladder does offer stays reachable. + return reasoning.sendsDefaultWhenUnset + ? { stops, currentStop: effective ?? '', label: effective ?? REASONING_OFF } + : { stops, currentStop: '', label: REASONING_PROVIDER_DEFAULT } + } + return { stops, currentStop: reasoning.value, label: reasoning.value } +} + +/** Which thinking control a chat should draw. */ +export type ReasoningControlState = + /** The flow sets the effort itself; show what the run will use. */ + | 'fixed' + /** No model chosen yet, so nothing can be said about its levels. */ + | 'awaiting-model' + /** No rules for this provider, and the chat takes a typed token. */ + | 'unknown' + /** Known levels — the ladder. */ + | 'ladder' + /** Known to have none. */ + | 'unsupported' + +/** + * The control is always drawn; only its state varies. Decided here rather than in the markup + * so the states sit in one readable, testable place. + */ +export function reasoningControlState( + reasoning: ChatModelSettingsReasoning | undefined, + capability: { supported: boolean; known: boolean } +): ReasoningControlState { + if (!reasoning || !reasoning.writable) return 'fixed' + if (!reasoning.model) return 'awaiting-model' + if (!capability.known && reasoning.typedWhenUnknown) return 'unknown' + return capability.supported ? 'ladder' : 'unsupported' +} + +/** + * What the row says when the chat cannot write the effort. Naming the level is the point: a + * button that names the model a run will use should name its thinking too, and a level fixed + * on a model that cannot use one is a broken flow worth seeing rather than a silent row. + */ +export function fixedReasoningReason( + reasoning: ChatModelSettingsReasoning | undefined, + capability: { supported: boolean; known: boolean } +): string { + const cannotThink = capability.known && !capability.supported + const model = reasoning?.model ?? 'this model' + if (!reasoning?.value) { + // The step names no effort, so the provider decides — saying it was "set in the flow" + // would describe a line the flow does not contain. + return cannotThink ? `${model} cannot think` : 'Not set in the flow, so the provider decides' + } + return cannotThink + ? `${reasoning.value} · set in the flow, but ${model} cannot think` + : `${reasoning.value} · set in the flow` +} diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts index b4c935bcaa..faf6f60264 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts @@ -77,9 +77,9 @@ describe('supportsReasoning (static registry)', () => { } // Bedrock translates the same sentinel on its Converse path, but only for // Opus 5 — AWS documents Bedrock's Sonnet 5 as always thinking. - expect( - getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable - ).toBe(true) + expect(getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable).toBe( + true + ) expect( resolveRequestReasoning({ provider: 'aws_bedrock', @@ -189,13 +189,29 @@ describe('supportsReasoning (static registry)', () => { expect(supportsReasoning('mistral', 'mistral-medium-3.5')).toBe(true) expect(getReasoningCapability('mistral', 'mistral-medium-3-5').canDisable).toBe(true) }) - it('returns no levels for providers without a registry entry', () => { + it('returns no levels for a model its provider family has no entry for', () => { + // The family is known, so the `false` is an answer: codestral does not reason. expect(getReasoningCapability('mistral', 'codestral-latest')).toEqual({ supported: false, levels: [], - canDisable: false + canDisable: false, + known: true }) }) + + // `customai` fronts any OpenAI-compatible endpoint, so `supported: false` there is an + // absence of rules rather than a fact about the model. A caller that shows the reader + // "this model cannot think" has to tell the two apart. + it('admits when it has no rules for the provider at all', () => { + expect(getReasoningCapability('customai', 'deepseek-r1')).toEqual({ + supported: false, + levels: [], + canDisable: false, + known: false + }) + expect(getReasoningCapability('openai', 'gpt-4o').known).toBe(true) + expect(getReasoningCapability('anthropic', 'claude-sonnet-5').known).toBe(true) + }) it('only offers off where the model can truly disable thinking', () => { // Gemini Pro enforces a thinking floor — no off option. expect(getReasoningCapability('googleai', 'gemini-2.5-pro').canDisable).toBe(false) diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 95a00e28c8..242f6cb329 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -231,14 +231,35 @@ export type ReasoningCapability = { * level, making the switch a lie. */ canDisable: boolean + /** + * Whether `supported` is an answer or an absence of one. The registry has rules per + * provider family and falls through to `false` for the rest — `customai` above all, + * which fronts any OpenAI-compatible endpoint and may well serve a thinking model. A + * caller that presents `supported: false` as a fact must check this first, or it tells + * the reader a model cannot think when all we know is that we have never heard of it. + */ + known: boolean } +/** Provider families the registry has real rules for; everything else is a shrug. */ +const KNOWN_REASONING_FAMILIES: ReadonlySet = new Set([ + 'anthropic', + 'aws_bedrock', + 'openai', + 'azure_openai', + 'openrouter', + 'googleai', + 'deepseek', + 'mistral' +]) + /** Resolve the reasoning capability of a model from the static registry. */ export function getReasoningCapability(provider: AIProvider, model: string): ReasoningCapability { const bareModel = stripLegacyThinkingSuffix(model) + const known = KNOWN_REASONING_FAMILIES.has(reasoningProviderFamily(provider, bareModel)) const supported = supportsReasoningStatic(provider, bareModel) if (!supported) { - return { supported: false, levels: [], canDisable: false } + return { supported: false, levels: [], canDisable: false, known } } const family = reasoningProviderFamily(provider, bareModel) const levels = @@ -251,7 +272,7 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea : family === 'openrouter' ? openrouterReasoningLevels(bareModel) : (PROVIDER_REASONING_LEVELS[family] ?? ['low', 'medium', 'high']) - return { supported, levels, canDisable: canDisableReasoning(provider, bareModel) } + return { supported, levels, canDisable: canDisableReasoning(provider, bareModel), known } } /** @@ -362,15 +383,11 @@ export function explicitOffToken(provider: AIProvider, model: string): Reasoning // real off there and stays the wire form. Only the 5 family, which // thinks when the field is absent, needs the explicit disable — // Fable and Mythos reject it outright and get no off token at all. - return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) - ? ANTHROPIC_OFF_SENTINEL - : undefined + return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) ? ANTHROPIC_OFF_SENTINEL : undefined case 'aws_bedrock': // Bedrock's Sonnet 5 cannot be disabled at all, so only Opus 5 gets // the sentinel; the rest keep omission. - return model.toLowerCase().includes('claude-opus-5') - ? ANTHROPIC_OFF_SENTINEL - : undefined + return model.toLowerCase().includes('claude-opus-5') ? ANTHROPIC_OFF_SENTINEL : undefined case 'googleai': // Gemini 2.5/3 think by default (dynamic budget / level). The backend // proxy maps 'none' to off on Flash, or the floor on Pro (only diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 7d4483d577..eb78dbdc89 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -851,6 +851,7 @@ path={$pathStore} hideSidebar={true} inputSchema={flowStore.val.schema} + flowModules={flowStore.val.value?.modules} /> diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index dc4eb92b57..1025ddaa15 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -6,6 +6,7 @@ import FlowChatInterface from './FlowChatInterface.svelte' import { getContext } from 'svelte' import type { FlowEditorContext } from '../types' + import type { FlowModule } from '$lib/gen' interface Props { /** @@ -22,6 +23,8 @@ path: string hideSidebar?: boolean inputSchema?: Record + /** The flow's modules, read for the provider wiring of its AI agent steps. */ + flowModules?: FlowModule[] /** The flow's description, shown under the empty transcript's prompt. */ description?: string wideLayout?: boolean @@ -33,6 +36,7 @@ path, hideSidebar = false, inputSchema = undefined, + flowModules = undefined, description = undefined, wideLayout = false }: Props = $props() @@ -102,6 +106,7 @@ {chat} {deploymentInProgress} {additionalInputsSchema} + {flowModules} {path} {workspace} {description} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 535be3ca83..f6aa0670c4 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -10,11 +10,23 @@ import { emptyString, type DynamicInput } from '$lib/utils' import { onDestroy, tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' + import type { FlowModule } from '$lib/gen' + import { deepEqual } from 'fast-equals' + import FlowChatModelSettings from './FlowChatModelSettings.svelte' + import { + agentModelGap, + composerOwnedInputs, + resolveAgentModelWiring, + showsModelButton, + withoutRejectedEffort + } from './agentChatInputs' interface Props { chat: Chat deploymentInProgress?: boolean additionalInputsSchema?: Record + /** The flow's modules, read for the provider wiring of its AI agent steps. */ + flowModules?: FlowModule[] path: string workspace?: string /** The flow's description, shown under the empty transcript's prompt. */ @@ -26,6 +38,7 @@ chat, deploymentInProgress = false, additionalInputsSchema, + flowModules, path, workspace = undefined, description = undefined, @@ -42,14 +55,45 @@ return undefined }) + // The model gets its own button, shaped like the copilot's model settings, driven by + // whichever provider fields the flow exposes. Every other flow input is asked for in + // the Configure-inputs modal. + const modelWiring = $derived(resolveAgentModelWiring(flowModules)) + // An agent with nothing to call cannot answer, and the composer cannot fix it, so the + // chat says what to go and do instead of offering controls that write nowhere. + const modelGap = $derived(agentModelGap(modelWiring)) + const showModelButton = $derived(showsModelButton(modelWiring)) + // LocalStorage helpers const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_' - // State for additional inputs modal let showInputsModal = $state(false) - let additionalInputsValues = $state | undefined>( - loadInputsFromStorage() ?? undefined - ) + // Conversation settings, persisted per flow: what the reader chose, and nothing else. + let inputValues = $state>(loadInputsFromStorage() ?? {}) + let modalDraft = $state>({}) + + /** What the flow's own form would open on. */ + function schemaDefaults(schema: Record | undefined): Record { + const properties: Record = schema?.properties ?? {} + return Object.fromEntries( + Object.entries(properties) + .filter(([, property]) => property?.default !== undefined) + .map(([name, property]) => [name, property.default]) + ) + } + + // Derived rather than seeded into `inputValues`: the schema arrives with the flow, which + // on the deployed page is after this mounts, and only what the reader actually chose + // belongs in storage. A stored value wins over the default, including a deliberate empty. + const effectiveInputs = $derived({ + ...schemaDefaults(additionalInputsSchema), + ...inputValues + }) + + // What the run actually gets. The composer's own controls keep themselves consistent as + // they are used; this is where a pair that was never chosen through them — a stored + // value, an author's default — is made safe before it reaches the provider. + const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs)) function getStorageKey(): string { return `${STORAGE_KEY_PREFIX}${path}` @@ -73,40 +117,72 @@ } } + function setInputValue(name: string, value: any) { + inputValues = { ...inputValues, [name]: value } + saveInputsToStorage(inputValues) + } + function handleModalConfirm() { - saveInputsToStorage(additionalInputsValues ?? {}) + // The modal opens on `effectiveInputs`, so its draft carries a value for every + // defaulted input whether or not the reader touched one. Storing those would pin + // today's defaults for good — `effectiveInputs` gives a stored value precedence, so + // a later change to the flow's schema would never reach this reader again. + const defaults = schemaDefaults(additionalInputsSchema) + const kept = Object.fromEntries( + Object.entries({ ...inputValues, ...modalDraft }).filter( + ([name, value]) => !deepEqual(value, defaults[name]) + ) + ) + inputValues = kept + saveInputsToStorage(inputValues) showInputsModal = false } function openInputsModal() { - const stored = loadInputsFromStorage() - if (stored) additionalInputsValues = stored + modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) } showInputsModal = true } - const hasMissingRequired = $derived.by(() => { - if (!additionalInputsSchema?.required?.length) return false - const values = additionalInputsValues ?? {} - return additionalInputsSchema.required.some( - (field: string) => - values[field] === undefined || values[field] === '' || values[field] === null - ) - }) - // The host follows the chat it was built on for the life of this component: FlowChat // remounts the interface under `{#key chat}`, so a later value of the prop never reaches it. const chatHost = new FlowChatViewHost( untrack(() => chat), { - additionalInputs: () => - additionalInputsSchema ? (loadInputsFromStorage() ?? additionalInputsValues) : undefined, + additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined), workspace: () => workspace, - sendDisabled: () => deploymentInProgress + sendDisabled: () => deploymentInProgress || !!modelGap } ) setChatViewHost(chatHost) onDestroy(() => chatHost.dispose()) + // What the Configure-inputs modal asks for: every flow input the composer does not + // edit itself. + const modalSchema = $derived.by(() => { + if (!additionalInputsSchema) return undefined + const promoted = new Set(composerOwnedInputs(modelWiring, undefined)) + const properties = Object.fromEntries( + Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key)) + ) + if (Object.keys(properties).length === 0) return undefined + const required: string[] = Array.isArray(additionalInputsSchema.required) + ? additionalInputsSchema.required + : [] + return { + ...additionalInputsSchema, + properties, + required: required.filter((key) => !promoted.has(key)) + } + }) + + const modalMissingRequired = $derived.by(() => { + if (!modalSchema?.required?.length) return false + return modalSchema.required.some((field: string) => { + const value = effectiveInputs[field] + return value === undefined || value === '' || value === null + }) + }) + // Older pages load when the reader reaches the top; the viewport stays where it was. let scrollElement = $state(undefined) let loadingOlder = false @@ -126,12 +202,11 @@ } - -{#if additionalInputsSchema} +{#if modalSchema} @@ -159,7 +234,7 @@ {/snippet} {#snippet footerSettings()} - {#if additionalInputsSchema} + {#if modalSchema}
- {#if hasMissingRequired} + {#if modalMissingRequired} {/if}
{/if} + {#if modelWiring && showModelButton} + + + {/if} {/snippet}