mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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 <noreply@anthropic.com> * feat: pick the flow chat's model and thinking from the provider fields the flow exposes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: name only the thinking level the flow run will send on the model button Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: promote a flow input to the model button only where its control can edit it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop any reasoning token the chosen model rejects before a flow chat run Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
68f2248018
commit
189793c2e4
Generated
+20
-5
@@ -5276,15 +5276,26 @@ tool, \`websearch\` for web search.
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
"resource": "$res:<path>", "model": <model id> }\`. 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -473,6 +473,7 @@
|
||||
hideSidebar={true}
|
||||
path={$pathStore}
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The model button every chat puts in the bottom-right of its composer: the trigger
|
||||
* names the model and its reasoning effort, and the menu holds the choices behind
|
||||
* both. Driven entirely by ChatModelSettingsConfig, so the session chat and the flow
|
||||
* chat render the same control from different data — see chatModelSettings.ts.
|
||||
*/
|
||||
import { ChevronDown, Check, Loader2 } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
|
||||
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
|
||||
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import ReasoningEffortSlider from './ReasoningEffortSlider.svelte'
|
||||
import { getReasoningCapability, resolveEffectiveReasoning } from './reasoningRegistry'
|
||||
import {
|
||||
fixedReasoningReason,
|
||||
reasoningControlState,
|
||||
reasoningDisplay,
|
||||
type ChatModelSettingsConfig,
|
||||
type ChoiceSection
|
||||
} from './chatModelSettings'
|
||||
import type { Item } from '$lib/utils'
|
||||
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type MeltItem = MenubarMenuElements['item']
|
||||
type MeltBuilders = ReturnType<typeof createDropdownMenu>['builders']
|
||||
|
||||
let { config }: { config: ChatModelSettingsConfig } = $props()
|
||||
|
||||
const reasoning = $derived(config.reasoning)
|
||||
const capability = $derived(
|
||||
reasoning?.provider && reasoning.model
|
||||
? getReasoningCapability(reasoning.provider, reasoning.model)
|
||||
: { supported: false, levels: [] as string[], canDisable: false, known: false }
|
||||
)
|
||||
const controlState = $derived(reasoningControlState(reasoning, capability))
|
||||
const fixedReason = $derived(fixedReasoningReason(reasoning, capability))
|
||||
// Effective effort accounts for the default-on level on capable models.
|
||||
const effective = $derived(
|
||||
reasoning?.provider && reasoning.model
|
||||
? resolveEffectiveReasoning({
|
||||
provider: reasoning.provider,
|
||||
model: reasoning.model,
|
||||
reasoning: reasoning.value
|
||||
})
|
||||
: undefined
|
||||
)
|
||||
// The stops, the one in use and the trigger's suffix are decided together, in one tested
|
||||
// place: a stop the slider shows as `off` must not read as the provider's `none` on the button.
|
||||
const display = $derived(reasoningDisplay(reasoning, capability, effective))
|
||||
const stops = $derived(display.stops)
|
||||
const currentStop = $derived(display.currentStop)
|
||||
const effortLabel = $derived(display.label)
|
||||
|
||||
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
|
||||
|
||||
// The trigger label resizes when the effort changes (dragging the slider while the menu
|
||||
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize
|
||||
// would shift the popover, so freeze the trigger to its width at open time and release it
|
||||
// on close — no movement while open, natural sizing the rest of the time.
|
||||
let menuOpen = $state(false)
|
||||
let triggerEl: HTMLElement | undefined = $state(undefined)
|
||||
let lockedWidth = $state<number | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
if (lockedWidth === undefined && triggerEl) {
|
||||
lockedWidth = triggerEl.getBoundingClientRect().width
|
||||
}
|
||||
} else {
|
||||
lockedWidth = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Blocks are separated, not prefixed: a rule belongs between two of them, so the first
|
||||
// one rendered must not draw one above itself whichever block that turns out to be.
|
||||
const BLOCK_CLASS =
|
||||
'border-border-light [&:not(:first-child)]:border-t [&:not(:first-child)]:mt-1 [&:not(:first-child)]:pt-1'
|
||||
|
||||
const ROW_CLASS =
|
||||
'w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer'
|
||||
</script>
|
||||
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
bind:this={triggerEl}
|
||||
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
|
||||
>
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
disabled={config.readOnly}
|
||||
endIcon={config.readOnly ? undefined : { icon: ChevronDown }}
|
||||
btnClasses="w-full max-w-[200px] text-secondary font-normal"
|
||||
title={config.readOnly ? config.readOnlyReason : config.title}
|
||||
>
|
||||
<span class="flex items-center gap-1 min-w-0">
|
||||
<span class="truncate">{config.label}</span>
|
||||
{#if effortLabel}
|
||||
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
|
||||
{/if}
|
||||
{#if config.badge}
|
||||
<span
|
||||
class={twMerge(
|
||||
'shrink-0 rounded-full px-1.5 text-2xs',
|
||||
config.badge.warn
|
||||
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
|
||||
: 'bg-surface-secondary text-tertiary'
|
||||
)}>{config.badge.text}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet typedField(
|
||||
value: string,
|
||||
placeholder: string,
|
||||
onCommit: (value: string) => void,
|
||||
close: () => void
|
||||
)}
|
||||
{#key value}
|
||||
<TextInput
|
||||
size="sm"
|
||||
{value}
|
||||
inputProps={{
|
||||
placeholder,
|
||||
onchange: (e) => 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)}
|
||||
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">{sec.label}</div>
|
||||
{#if sec.loading}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 text-tertiary">
|
||||
<Loader2 size={14} class="animate-spin" /> Loading...
|
||||
</div>
|
||||
{:else if sec.options.length === 0}
|
||||
<div class="px-3 py-1.5 text-tertiary">{sec.emptyMessage ?? 'Nothing to choose from'}</div>
|
||||
{:else}
|
||||
<div class={twMerge('overflow-y-auto', sec.maxHeight ?? 'max-h-48')}>
|
||||
{#each sec.options as option (option.key)}
|
||||
<MenuItem {item} class={ROW_CLASS} onClick={() => option.onSelect()}>
|
||||
<span class="truncate grow min-w-0">{option.label}</span>
|
||||
{#if option.hint}
|
||||
<span class="shrink-0 text-tertiary truncate max-w-[70px]">{option.hint}</span>
|
||||
{/if}
|
||||
{#if option.selected}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if sec.custom && !sec.loading}
|
||||
{@const custom = sec.custom}
|
||||
<div class="px-3 pt-1 pb-1.5">
|
||||
{@render typedField(
|
||||
'',
|
||||
custom.placeholder,
|
||||
(value) => {
|
||||
if (value) custom.onCommit(value)
|
||||
},
|
||||
close
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)}
|
||||
{#each items.filter((row) => !row.hide) as row (row.displayName)}
|
||||
{#if row.separatorTop}
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{/if}
|
||||
{#if row.submenuItems}
|
||||
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
|
||||
<DropdownSubmenuItem item={row} {builders} meltItem={item} />
|
||||
{:else}
|
||||
<MenuItem {item} class={ROW_CLASS} onClick={(e) => row.action?.(e)}>
|
||||
{#if row.icon}
|
||||
<row.icon size={14} class="shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate grow min-w-0 text-2xs text-secondary">{row.displayName}</span>
|
||||
{#if row.selected}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#if config.readOnly}
|
||||
{@render trigger()}
|
||||
{:else}
|
||||
<DropdownV2
|
||||
customMenu
|
||||
placement="bottom-end"
|
||||
fixedHeight={false}
|
||||
closeOnItemClick={false}
|
||||
bind:open={menuOpen}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
{@render trigger()}
|
||||
{/snippet}
|
||||
{#snippet menu({ item, builders, close })}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
|
||||
>
|
||||
{#if config.topItems}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{@render rows(config.topItems(close), item, builders)}
|
||||
</div>
|
||||
{/if}
|
||||
{#each config.sections ?? [] as sec (sec.label)}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{@render section(sec, item, close)}
|
||||
</div>
|
||||
{/each}
|
||||
{#if reasoning}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{#if controlState === 'fixed'}
|
||||
<ReasoningEffortSlider
|
||||
stops={[]}
|
||||
current=""
|
||||
onSelect={() => {}}
|
||||
unsupportedReason={fixedReason}
|
||||
/>
|
||||
{:else if controlState === 'awaiting-model'}
|
||||
<ReasoningEffortSlider
|
||||
stops={[]}
|
||||
current=""
|
||||
onSelect={() => {}}
|
||||
unsupportedReason="Pick a model first"
|
||||
/>
|
||||
{:else if controlState === 'unknown'}
|
||||
<!-- No rules for this provider, so no ladder to offer. The flow still takes a
|
||||
token, so it is typed rather than picked: claiming the model cannot think
|
||||
would be a guess, and offering nothing would leave it settable nowhere. -->
|
||||
<div class="px-3 pt-1 pb-1.5">
|
||||
<div class="text-2xs uppercase tracking-wide text-secondary mb-1">Thinking</div>
|
||||
{@render typedField(reasoning.value ?? '', 'none', reasoning.onSelect, close)}
|
||||
<div class="text-2xs text-tertiary mt-1">
|
||||
Windmill has no thinking levels for this provider — type what it accepts.
|
||||
</div>
|
||||
</div>
|
||||
{:else if controlState === 'ladder'}
|
||||
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
|
||||
up/down navigation), and so hovering it takes the highlight off the row
|
||||
above. Left/right adjust the effort; the slider's input handler also drives it. -->
|
||||
<MenuItemWrapper
|
||||
{item}
|
||||
onKeydown={(e) => effortSlider?.adjust(e)}
|
||||
class="block group"
|
||||
>
|
||||
<ReasoningEffortSlider
|
||||
bind:this={effortSlider}
|
||||
{stops}
|
||||
current={currentStop}
|
||||
onSelect={reasoning.onSelect}
|
||||
format={(stop) => (stop === reasoning?.offToken ? 'off' : stop)}
|
||||
overrideLabel={stops.includes(currentStop) ? undefined : effortLabel}
|
||||
/>
|
||||
</MenuItemWrapper>
|
||||
{:else}
|
||||
<!-- Kept in place rather than dropped: the row saying the model cannot think
|
||||
is the answer to why there is no slider. -->
|
||||
<ReasoningEffortSlider
|
||||
stops={[]}
|
||||
current=""
|
||||
onSelect={() => {}}
|
||||
unsupportedReason="Not supported by this model"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if config.bottomItems}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{@render rows(config.bottomItems(close), item, builders)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The reasoning-effort control: a thin slider over a model's ordered effort stops.
|
||||
*
|
||||
* Presentational on purpose. Callers keep their own value convention — the copilot's
|
||||
* REASONING_OFF sentinel and an agent's `reasoning_effort` token mean off in
|
||||
* different ways — and hand this component a resolved list of stops plus the current
|
||||
* one, so the two never have to agree on anything but the ordering.
|
||||
*/
|
||||
interface Props {
|
||||
/** Ordered stops, least effort first. Fewer than two renders no slider. */
|
||||
stops: string[]
|
||||
current: string
|
||||
onSelect: (stop: string) => void
|
||||
/** When set, the section renders disabled with this as the explanation. */
|
||||
unsupportedReason?: string
|
||||
/** Display name for a stop whose value is a provider sentinel rather than a word. */
|
||||
format?: (stop: string) => string
|
||||
/** Shown in place of the current stop — a state the slider has no position for. */
|
||||
overrideLabel?: string
|
||||
}
|
||||
|
||||
let {
|
||||
stops,
|
||||
current,
|
||||
onSelect,
|
||||
unsupportedReason,
|
||||
format = (stop: string) => stop,
|
||||
overrideLabel
|
||||
}: Props = $props()
|
||||
|
||||
/**
|
||||
* A `current` naming no stop is a real state, not a missing one: an agent that leaves the
|
||||
* effort unset sends nothing and the provider decides. Three things follow, and they only
|
||||
* hold together.
|
||||
*
|
||||
* The thumb rests at the start, because a range input always has one somewhere, and
|
||||
* `overrideLabel` is what tells the reader this is not the lowest stop. The track is
|
||||
* unfilled there, which index 0 gives for free. And since the input's value already reads
|
||||
* 0, picking the lowest stop by pointer fires no `input` event — so a click has to be
|
||||
* committed explicitly, or that stop is reachable only by keyboard.
|
||||
*/
|
||||
const hasPosition = $derived(stops.indexOf(current) >= 0)
|
||||
const stopIndex = $derived(Math.max(0, stops.indexOf(current)))
|
||||
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
|
||||
const fillPct = $derived(
|
||||
stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0
|
||||
)
|
||||
|
||||
/** Left/right stepping, for a caller that owns the keyboard (a melt menu item). */
|
||||
export function adjust(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const next = Math.min(
|
||||
stops.length - 1,
|
||||
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
|
||||
)
|
||||
onSelect(stops[next])
|
||||
}
|
||||
|
||||
// Melt's roving focus blurs the focused element on pointermove, which aborts a native
|
||||
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
|
||||
function isolatePointer(node: HTMLElement) {
|
||||
const stop = (e: Event) => e.stopPropagation()
|
||||
node.addEventListener('pointerdown', stop)
|
||||
node.addEventListener('pointermove', stop)
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointerdown', stop)
|
||||
node.removeEventListener('pointermove', stop)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if unsupportedReason}
|
||||
<!-- Kept visible rather than hidden: the absence of the control is itself the answer,
|
||||
but only if it says why. -->
|
||||
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
|
||||
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
|
||||
<div class="text-2xs text-tertiary mt-0.5">{unsupportedReason}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
|
||||
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
|
||||
<span class="text-2xs text-secondary tabular-nums">{overrideLabel ?? format(current)}</span>
|
||||
</div>
|
||||
{#if stops.length > 1}
|
||||
<!-- Only the slider area reflects an enclosing menu item's highlight, not the header. -->
|
||||
<div class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={stops.length - 1}
|
||||
step="1"
|
||||
value={stopIndex}
|
||||
style="--fill: {fillPct}%"
|
||||
oninput={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
|
||||
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
|
||||
rules — so they are wrapped in :global (the class is unique to this component). */
|
||||
.lean-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* override the global `input { background-color: ... !important }` so only the
|
||||
thin track shows, not a full-height band behind it */
|
||||
background-color: transparent !important;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.lean-range:focus,
|
||||
.lean-range:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-runnable-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
|
||||
rgb(var(--color-surface-secondary)) var(--fill, 0%)
|
||||
);
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-thumb) {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -3.5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-secondary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-progress) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-thumb) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
|
||||
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
|
||||
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
/**
|
||||
* The session chat's model button: a fixed ChatModelSettings config over the copilot's
|
||||
* own state — the workspace's configured models, the session's model/effort selection
|
||||
* and its localStorage pins, the custom-prompt editors, and the free-tier grant.
|
||||
*/
|
||||
import { User, Building2, Settings, ExternalLink } from 'lucide-svelte'
|
||||
import ChatModelSettings from '../ChatModelSettings.svelte'
|
||||
import { carriedReasoning, type ChatModelSettingsConfig } from '../chatModelSettings'
|
||||
import {
|
||||
COPILOT_SESSION_MODEL_SETTING_NAME,
|
||||
COPILOT_SESSION_PROVIDER_SETTING_NAME,
|
||||
@@ -28,7 +30,6 @@
|
||||
import { thinkingPreferences } from './thinkingPreferences.svelte'
|
||||
import {
|
||||
getReasoningCapability,
|
||||
resolveEffectiveReasoning,
|
||||
REASONING_OFF,
|
||||
type ReasoningProviderModel
|
||||
} from '../reasoningRegistry'
|
||||
@@ -60,57 +61,16 @@
|
||||
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
|
||||
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
|
||||
|
||||
let capability = $derived(
|
||||
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
|
||||
)
|
||||
// Effective effort accounts for the default-on level on capable models.
|
||||
let currentEffort = $derived(resolveEffectiveReasoning(providerModel))
|
||||
// Slider stops: 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.
|
||||
let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels])
|
||||
let currentStop = $derived(
|
||||
providerModel.reasoning === REASONING_OFF
|
||||
? REASONING_OFF
|
||||
: (currentEffort ?? stops[stops.length - 1])
|
||||
)
|
||||
let stopIndex = $derived(Math.max(0, stops.indexOf(currentStop)))
|
||||
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
|
||||
let fillPct = $derived(stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0)
|
||||
// Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely
|
||||
// for models with no reasoning support.
|
||||
let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined)
|
||||
|
||||
// The trigger label resizes when the effort changes (e.g. dragging the slider while the menu
|
||||
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would
|
||||
// shift the popover. So we freeze the trigger to its width at open time and release it on close —
|
||||
// no movement while open, and natural sizing (no reserved padding) the rest of the time.
|
||||
let menuOpen = $state(false)
|
||||
let triggerEl: HTMLElement | undefined = $state(undefined)
|
||||
let lockedWidth = $state<number | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
if (lockedWidth === undefined && triggerEl) {
|
||||
lockedWidth = triggerEl.getBoundingClientRect().width
|
||||
}
|
||||
} else {
|
||||
lockedWidth = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function selectModel(m: AIProviderModel) {
|
||||
// Carry the effort onto the new model only if it supports that level ('off'
|
||||
// only where the model can truly disable); otherwise drop it so the model's
|
||||
// default applies.
|
||||
const carried = providerModel.reasoning
|
||||
const cap = getReasoningCapability(m.provider, m.model)
|
||||
const keep =
|
||||
carried === REASONING_OFF
|
||||
? cap.canDisable
|
||||
: carried !== undefined && cap.levels.includes(carried)
|
||||
$copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) }
|
||||
const keep = carriedReasoning(
|
||||
providerModel.reasoning,
|
||||
REASONING_OFF,
|
||||
getReasoningCapability(m.provider, m.model)
|
||||
)
|
||||
$copilotSessionModel = { ...m, ...(keep !== undefined ? { reasoning: keep } : {}) }
|
||||
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
|
||||
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
|
||||
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined)
|
||||
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep)
|
||||
}
|
||||
|
||||
function selectReasoning(value: string) {
|
||||
@@ -233,9 +193,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned,
|
||||
// so it flips on screen edges instead of overflowing). The menu keeps itself open on
|
||||
// item click (closeOnItemClick=false), so these actions close it explicitly via `close`.
|
||||
// Prompt parameters, surfaced as a melt submenu. The menu keeps itself open on item
|
||||
// click, so these actions close it explicitly before opening a modal.
|
||||
function paramItems(close: () => void): Item {
|
||||
return {
|
||||
displayName: 'Parameters',
|
||||
@@ -270,155 +229,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the slider's pointer events from bubbling to the enclosing melt item: melt's
|
||||
// roving focus blurs the focused element on pointermove, which would abort the native
|
||||
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
|
||||
function isolatePointer(node: HTMLElement) {
|
||||
const stop = (e: Event) => e.stopPropagation()
|
||||
node.addEventListener('pointerdown', stop)
|
||||
node.addEventListener('pointermove', stop)
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointerdown', stop)
|
||||
node.removeEventListener('pointermove', stop)
|
||||
const config = $derived<ChatModelSettingsConfig>({
|
||||
label: providerModel.model,
|
||||
title: 'Model & reasoning settings',
|
||||
badge: freeTier && !freeTier.exhausted ? { text: 'Free', warn: freeRunningLow } : undefined,
|
||||
// Off in a session: the assistant settings modal's Instructions section owns the
|
||||
// prompt entries there, so the menu would offer the same thing twice.
|
||||
topItems: promptSettings ? (close) => [paramItems(close)] : undefined,
|
||||
sections: [
|
||||
{
|
||||
label: 'Model',
|
||||
options: models.map((m) => ({
|
||||
key: `${m.provider}/${m.model}`,
|
||||
label: m.model,
|
||||
selected: m.model === providerModel.model && m.provider === providerModel.provider,
|
||||
onSelect: () => selectModel(m)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust the reasoning effort with the arrow keys while the Thinking item is focused.
|
||||
function adjustEffort(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const next = Math.min(
|
||||
stops.length - 1,
|
||||
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
|
||||
)
|
||||
selectReasoning(stops[next])
|
||||
}
|
||||
],
|
||||
reasoning: {
|
||||
provider: providerModel.provider as AIProvider,
|
||||
model: providerModel.model,
|
||||
value: providerModel.reasoning,
|
||||
offToken: REASONING_OFF,
|
||||
// The copilot fills an unset effort in before it calls the provider, so unset
|
||||
// really does run at the default level and the button may name it.
|
||||
sendsDefaultWhenUnset: true,
|
||||
// The session chat's model is always its own to change.
|
||||
writable: true,
|
||||
typedWhenUnknown: false,
|
||||
onSelect: selectReasoning
|
||||
},
|
||||
// A reading preference rather than a model parameter: it applies to every chat in
|
||||
// this browser, including thinking already in the transcript. No close(): flipping
|
||||
// it should not dismiss the menu.
|
||||
bottomItems: () => [
|
||||
{
|
||||
displayName: 'Always expand thinking',
|
||||
selected: thinkingPreferences.expandByDefault,
|
||||
action: () => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet externalLinkIcon()}
|
||||
<ExternalLink size={14} class="shrink-0 text-secondary" />
|
||||
{/snippet}
|
||||
|
||||
<DropdownV2
|
||||
customMenu
|
||||
placement="bottom-end"
|
||||
fixedHeight={false}
|
||||
closeOnItemClick={false}
|
||||
bind:open={menuOpen}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
bind:this={triggerEl}
|
||||
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
|
||||
>
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
btnClasses="w-full max-w-[200px] text-secondary font-normal"
|
||||
title="Model & reasoning settings"
|
||||
>
|
||||
<span class="flex items-center gap-1 min-w-0">
|
||||
<span class="truncate">{providerModel.model}</span>
|
||||
{#if effortLabel}
|
||||
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
|
||||
{/if}
|
||||
{#if freeTier && !freeTier.exhausted}
|
||||
<span
|
||||
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
|
||||
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
|
||||
: 'bg-surface-secondary text-tertiary'}">Free</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu({ item, builders, close })}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
|
||||
>
|
||||
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
|
||||
{#if promptSettings}
|
||||
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
|
||||
{/if}
|
||||
<ChatModelSettings {config} />
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
{#each models as m (m.provider + m.model)}
|
||||
<MenuItem
|
||||
{item}
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
|
||||
onClick={() => selectModel(m)}
|
||||
>
|
||||
<span class="truncate grow min-w-0">{m.model}</span>
|
||||
{#if m.model === providerModel.model && m.provider === providerModel.provider}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{#if capability.supported}
|
||||
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
|
||||
up/down navigation), and so hovering it takes the highlight off the Parameters
|
||||
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
|
||||
<MenuItemWrapper {item} onKeydown={adjustEffort} class="block group">
|
||||
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
|
||||
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
|
||||
<span class="text-2xs text-secondary tabular-nums">{currentStop}</span>
|
||||
</div>
|
||||
{#if stops.length > 1}
|
||||
<!-- Only the slider area reflects the item's highlight, not the header. -->
|
||||
<div
|
||||
class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={stops.length - 1}
|
||||
step="1"
|
||||
value={stopIndex}
|
||||
style="--fill: {fillPct}%"
|
||||
oninput={(e) => selectReasoning(stops[+e.currentTarget.value])}
|
||||
use:isolatePointer
|
||||
class="lean-range no-default-style w-full"
|
||||
aria-label="Reasoning effort"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</MenuItemWrapper>
|
||||
{:else}
|
||||
<!-- Reasoning unsupported: keep the section but show it disabled with a reason,
|
||||
rather than hiding it. Not a melt item, so it's skipped by keyboard navigation. -->
|
||||
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
|
||||
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
|
||||
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- A reading preference rather than a model parameter: it applies to every
|
||||
chat in this browser, including thinking already in the transcript. -->
|
||||
<MenuItem
|
||||
{item}
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
|
||||
onClick={() => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)}
|
||||
>
|
||||
<span class="truncate grow min-w-0 text-2xs text-secondary">Always expand thinking</span>
|
||||
{#if thinkingPreferences.expandByDefault}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<!-- Only where the entries that open it are rendered. -->
|
||||
{#if promptSettings}
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
@@ -436,61 +296,3 @@
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
|
||||
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
|
||||
rules — so they are wrapped in :global (the class is unique to this component). */
|
||||
.lean-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* override the global `input { background-color: ... !important }` so only the
|
||||
thin track shows, not a full-height band behind it */
|
||||
background-color: transparent !important;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.lean-range:focus,
|
||||
.lean-range:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-runnable-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
|
||||
rgb(var(--color-surface-secondary)) var(--fill, 0%)
|
||||
);
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-thumb) {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -3.5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-secondary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-progress) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-thumb) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<ChatModelSettingsReasoning> & { 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>): 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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string> = 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
|
||||
|
||||
@@ -851,6 +851,7 @@
|
||||
path={$pathStore}
|
||||
hideSidebar={true}
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, any>
|
||||
/** 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}
|
||||
|
||||
@@ -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<string, any>
|
||||
/** 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<Record<string, any> | undefined>(
|
||||
loadInputsFromStorage() ?? undefined
|
||||
)
|
||||
// Conversation settings, persisted per flow: what the reader chose, and nothing else.
|
||||
let inputValues = $state<Record<string, any>>(loadInputsFromStorage() ?? {})
|
||||
let modalDraft = $state<Record<string, any>>({})
|
||||
|
||||
/** What the flow's own form would open on. */
|
||||
function schemaDefaults(schema: Record<string, any> | undefined): Record<string, any> {
|
||||
const properties: Record<string, any> = 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<HTMLDivElement | undefined>(undefined)
|
||||
let loadingOlder = false
|
||||
@@ -126,12 +202,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Additional Inputs Modal -->
|
||||
{#if additionalInputsSchema}
|
||||
{#if modalSchema}
|
||||
<Modal title="Configure inputs" bind:open={showInputsModal}>
|
||||
<SchemaForm
|
||||
schema={additionalInputsSchema}
|
||||
bind:args={additionalInputsValues}
|
||||
schema={modalSchema}
|
||||
bind:args={modalDraft}
|
||||
helperScript={dynamicInputHelperScript}
|
||||
{workspace}
|
||||
/>
|
||||
@@ -159,7 +234,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet footerSettings()}
|
||||
{#if additionalInputsSchema}
|
||||
{#if modalSchema}
|
||||
<div class="relative">
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
@@ -171,11 +246,21 @@
|
||||
>
|
||||
Inputs
|
||||
</Button>
|
||||
{#if hasMissingRequired}
|
||||
{#if modalMissingRequired}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if modelWiring && showModelButton}
|
||||
<!-- `runInputs`, not `effectiveInputs`: a stored effort the model rejects is dropped
|
||||
before the run, and the button must not name one the run will not send. -->
|
||||
<FlowChatModelSettings
|
||||
wiring={modelWiring}
|
||||
values={runInputs}
|
||||
setValue={setInputValue}
|
||||
{workspace}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- The transcript scroller fills its flex row, which needs a height to resolve
|
||||
@@ -200,10 +285,10 @@
|
||||
hideModeSelector
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
footerSettings={additionalInputsSchema ? footerSettings : undefined}
|
||||
footerSettings={modalSchema || showModelButton ? footerSettings : undefined}
|
||||
placeholder="Send a message to run the flow"
|
||||
disabled={deploymentInProgress}
|
||||
disabledMessage={deploymentInProgress ? 'Deployment in progress' : ''}
|
||||
disabled={deploymentInProgress || !!modelGap}
|
||||
disabledMessage={deploymentInProgress ? 'Deployment in progress' : (modelGap ?? '')}
|
||||
loadPastChat={() => {}}
|
||||
deletePastChat={() => {}}
|
||||
saveAndClear={() => {}}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The flow chat's model button: the same ChatModelSettings the session chat renders,
|
||||
* over whatever the flow exposes.
|
||||
*
|
||||
* The agent takes one `provider` object, but an author can expose it field by field —
|
||||
* fixing the resource in the step and letting the chat pick only the model, say. Each
|
||||
* control here appears exactly when the flow wired the field behind it, so a chat never
|
||||
* offers a knob whose value it could not write back.
|
||||
*/
|
||||
import ChatModelSettings from '$lib/components/copilot/ChatModelSettings.svelte'
|
||||
import {
|
||||
carriedReasoning,
|
||||
type ChatModelSettingsConfig
|
||||
} from '$lib/components/copilot/chatModelSettings'
|
||||
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
|
||||
import { AI_PROVIDERS, fetchAvailableModels } from '$lib/components/copilot/lib'
|
||||
import {
|
||||
explicitOffToken,
|
||||
getReasoningCapability
|
||||
} from '$lib/components/copilot/reasoningRegistry'
|
||||
import { ResourceService, type AIProvider } from '$lib/gen'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Plug, Plus } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import {
|
||||
composerDrivenFields,
|
||||
type AgentModelWiring,
|
||||
type ProviderField
|
||||
} from './agentChatInputs'
|
||||
|
||||
interface Props {
|
||||
wiring: AgentModelWiring
|
||||
/** Every flow input value the composer holds for this conversation. */
|
||||
values: Record<string, any>
|
||||
setValue: (name: string, value: any) => void
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
let { wiring, values, setValue, workspace }: Props = $props()
|
||||
|
||||
function fieldValue(field: ProviderField): any {
|
||||
if (wiring.whole) return values[wiring.whole]?.[field]
|
||||
const name = wiring.fields[field]
|
||||
return name ? values[name] : wiring.fixed[field]
|
||||
}
|
||||
|
||||
const driven = $derived(composerDrivenFields(wiring))
|
||||
|
||||
function editable(field: ProviderField): boolean {
|
||||
return driven.has(field)
|
||||
}
|
||||
|
||||
/** Written together, because choosing a resource also invalidates the model. */
|
||||
function setFields(patch: Partial<Record<ProviderField, any>>) {
|
||||
if (wiring.whole) {
|
||||
setValue(wiring.whole, { ...(values[wiring.whole] ?? {}), ...patch })
|
||||
return
|
||||
}
|
||||
for (const [field, value] of Object.entries(patch)) {
|
||||
const name = wiring.fields[field as ProviderField]
|
||||
if (name) setValue(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
const resourceEditable = $derived(editable('resource'))
|
||||
const modelEditable = $derived(editable('model'))
|
||||
const effortEditable = $derived(editable('reasoning_effort'))
|
||||
// Wired, but left to the Configure-inputs modal: the button has no model to place it on.
|
||||
const effortInModal = $derived(wiring.fields.reasoning_effort !== undefined && !effortEditable)
|
||||
// Nothing to write: the flow fixes the lot, so the button names it and opens nothing.
|
||||
const readOnly = $derived(!resourceEditable && !modelEditable && !effortEditable)
|
||||
|
||||
const AI_RESOURCE_TYPES = Object.keys(AI_PROVIDERS)
|
||||
|
||||
// `$res:` is the stored form; the picker works in bare paths.
|
||||
const resourcePath = $derived(
|
||||
typeof fieldValue('resource') === 'string'
|
||||
? fieldValue('resource').replace(/^\$res:/, '') || undefined
|
||||
: undefined
|
||||
)
|
||||
const model = $derived(fieldValue('model'))
|
||||
const effort = $derived(fieldValue('reasoning_effort'))
|
||||
|
||||
let appConnect: AppConnect | undefined = $state(undefined)
|
||||
// Bumped after the connect drawer creates one, to re-list.
|
||||
let resourcesVersion = $state(0)
|
||||
// Set when a resource is created here: it can only be selected once the re-listing
|
||||
// that follows tells us which provider it speaks.
|
||||
let pendingResourcePath = $state<string | undefined>(undefined)
|
||||
|
||||
// A flow that fixes `kind` but exposes `resource` accepts resources of that kind only:
|
||||
// `setFields` drops a `kind` it cannot write, so any other provider's resource would be
|
||||
// listed, selected, and then run against the kind the flow still fixes.
|
||||
const allowedResourceTypes = $derived.by(() => {
|
||||
const fixedKind = editable('kind') ? undefined : (fieldValue('kind') as string | undefined)
|
||||
return fixedKind && AI_RESOURCE_TYPES.includes(fixedKind) ? [fixedKind] : AI_RESOURCE_TYPES
|
||||
})
|
||||
|
||||
const resources = resource(
|
||||
() =>
|
||||
resourceEditable ? { workspace, version: resourcesVersion, allowedResourceTypes } : undefined,
|
||||
async (args) => {
|
||||
const ws = args?.workspace
|
||||
if (!ws) return []
|
||||
const rows = await ResourceService.listResource({
|
||||
workspace: ws,
|
||||
resourceType: (args?.allowedResourceTypes ?? AI_RESOURCE_TYPES).join(',')
|
||||
})
|
||||
return rows.map((r) => ({
|
||||
path: r.path,
|
||||
// The row's own type is the provider; an unrecognised one is a custom endpoint.
|
||||
provider: (AI_RESOURCE_TYPES.includes(r.resource_type ?? '')
|
||||
? r.resource_type
|
||||
: 'customai') as AIProvider
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
const provider = $derived(
|
||||
resources.current?.find((r) => r.path === resourcePath)?.provider ??
|
||||
(fieldValue('kind') as AIProvider | undefined)
|
||||
)
|
||||
|
||||
// Models the resource actually serves, asked of the provider. Its own catalogue is the
|
||||
// fallback, so a listing that fails or is unsupported still offers real ids rather than
|
||||
// an empty menu.
|
||||
const models = resource(
|
||||
() => ({ workspace, resourcePath, provider, modelEditable }),
|
||||
async ({ workspace, resourcePath, provider, modelEditable }, _prev, { onCleanup }) => {
|
||||
if (!modelEditable || !provider) return []
|
||||
const fallback = AI_PROVIDERS[provider]?.defaultModels ?? []
|
||||
if (!workspace || !resourcePath) return fallback
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
try {
|
||||
const listed = await fetchAvailableModels(
|
||||
resourcePath,
|
||||
workspace,
|
||||
provider,
|
||||
controller.signal
|
||||
)
|
||||
return listed.length > 0 ? listed : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (!pendingResourcePath) return
|
||||
const created = resources.current?.find((r) => r.path === pendingResourcePath)
|
||||
if (created) {
|
||||
pendingResourcePath = undefined
|
||||
selectResource(created.path, created.provider)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The effort to write alongside a new model: `''` — the agent's "no effort" — wherever
|
||||
* that model has no such level, so the composer never leaves behind a level the provider
|
||||
* would reject. Writes nothing where the registry cannot speak for the model, since
|
||||
* clearing a value on a guess would destroy the author's own default.
|
||||
*/
|
||||
function effortPatch(nextModel: string | undefined): Partial<Record<ProviderField, any>> {
|
||||
if (!provider || !nextModel) return {}
|
||||
const capability = getReasoningCapability(provider, nextModel)
|
||||
if (!capability.known) return {}
|
||||
const carried = carriedReasoning(
|
||||
typeof effort === 'string' ? effort : undefined,
|
||||
explicitOffToken(provider, nextModel) ?? '',
|
||||
capability
|
||||
)
|
||||
return { reasoning_effort: carried ?? '' }
|
||||
}
|
||||
|
||||
function selectResource(path: string, picked: AIProvider) {
|
||||
setFields({
|
||||
kind: picked,
|
||||
resource: `$res:${path}`,
|
||||
// The models of one provider mean nothing to another, and the new list only
|
||||
// arrives async, so there is nothing to carry the current one against — nor the
|
||||
// effort, which only means something against a model. Cleared only where the
|
||||
// registry can speak for the new provider, for the same reason as `effortPatch`.
|
||||
// `''`, not `undefined`: storage drops an undefined key, and the schema's default
|
||||
// model would then come back under the new provider on reload.
|
||||
model: '',
|
||||
...(getReasoningCapability(picked, '').known ? { reasoning_effort: '' } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function providerItem(close: () => void): Item {
|
||||
const rows: Item[] = resources.loading
|
||||
? [{ displayName: 'Loading resources...', disabled: true }]
|
||||
: (resources.current ?? []).length === 0
|
||||
? [{ displayName: 'No AI resource in this workspace', disabled: true }]
|
||||
: (resources.current ?? []).map((r) => ({
|
||||
displayName: r.path,
|
||||
selected: r.path === resourcePath,
|
||||
action: () => selectResource(r.path, r.provider)
|
||||
}))
|
||||
return {
|
||||
displayName: 'Provider',
|
||||
icon: Plug,
|
||||
extra: providerSummary,
|
||||
submenuItems: [
|
||||
...rows,
|
||||
{
|
||||
// The same reach the form's ResourcePicker gives: create one without
|
||||
// leaving for workspace settings first.
|
||||
displayName: 'Add a resource',
|
||||
icon: Plus,
|
||||
separatorTop: true,
|
||||
action: () => {
|
||||
close()
|
||||
appConnect?.open()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const config = $derived<ChatModelSettingsConfig>({
|
||||
label: typeof model === 'string' && model ? model : 'Select a model',
|
||||
title: 'Model & reasoning settings',
|
||||
readOnly,
|
||||
readOnlyReason: 'Set in the flow',
|
||||
topItems: resourceEditable ? (close) => [providerItem(close)] : undefined,
|
||||
sections: modelEditable
|
||||
? [
|
||||
{
|
||||
label: 'Model',
|
||||
options: (models.current ?? []).map((m) => ({
|
||||
key: m,
|
||||
label: m,
|
||||
selected: m === model,
|
||||
onSelect: () => setFields({ model: m, ...effortPatch(m) })
|
||||
})),
|
||||
loading: models.loading,
|
||||
emptyMessage: provider ? 'No model listed' : 'Pick a provider first',
|
||||
// The step's own provider picker takes any model id, and the modal does not
|
||||
// ask for this input: without a typed entry, an endpoint that lists nothing
|
||||
// leaves the run with no model.
|
||||
custom: provider
|
||||
? {
|
||||
placeholder: 'Custom model id',
|
||||
onCommit: (m) => setFields({ model: m, ...effortPatch(m) })
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
: undefined,
|
||||
// Present whatever we can say about it, since the run uses an effort either way: a ladder,
|
||||
// a typed token, why there is none, or what the flow fixed. Absent only when the modal
|
||||
// is the effort's editor.
|
||||
reasoning: effortInModal
|
||||
? undefined
|
||||
: {
|
||||
provider,
|
||||
model: typeof model === 'string' && model ? model : undefined,
|
||||
value: typeof effort === 'string' ? effort : undefined,
|
||||
// An agent writes the provider-native token straight into its step, so there is no
|
||||
// sentinel to translate later. Where a model disables by omission instead, the empty
|
||||
// string is that off: the run reads an empty `reasoning_effort` as absent
|
||||
// (types.rs `get_reasoning_effort`).
|
||||
offToken:
|
||||
provider && typeof model === 'string' && model
|
||||
? (explicitOffToken(provider, model) ?? '')
|
||||
: '',
|
||||
// An agent step omits `reasoning_effort` when it is unset, so the provider picks —
|
||||
// naming a level would claim something the run does not do.
|
||||
sendsDefaultWhenUnset: false,
|
||||
writable: effortEditable,
|
||||
typedWhenUnknown: true,
|
||||
onSelect: (token) => setFields({ reasoning_effort: token })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet providerSummary()}
|
||||
{#if resourcePath}
|
||||
<span class="shrink-0 text-tertiary truncate max-w-[80px]">{resourcePath}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if resourceEditable}
|
||||
<AppConnect
|
||||
bind:this={appConnect}
|
||||
{workspace}
|
||||
on:refresh={(e) => {
|
||||
resourcesVersion++
|
||||
if (e.detail) {
|
||||
pendingResourcePath = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ChatModelSettings {config} />
|
||||
@@ -0,0 +1,492 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentModelGap,
|
||||
agentModelWiringInputs,
|
||||
composerOwnedInputs,
|
||||
parseProviderTransform,
|
||||
resolveAgentModelWiring,
|
||||
showsModelButton,
|
||||
withoutRejectedEffort
|
||||
} from './agentChatInputs'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
function agent(expr: string): FlowModule {
|
||||
return {
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: { provider: { type: 'javascript', expr } }
|
||||
}
|
||||
} as unknown as FlowModule
|
||||
}
|
||||
|
||||
describe('parseProviderTransform', () => {
|
||||
it('reads a whole-object reference', () => {
|
||||
expect(parseProviderTransform({ type: 'javascript', expr: 'flow_input.model' })).toEqual({
|
||||
whole: 'model',
|
||||
fields: {},
|
||||
fixed: {}
|
||||
})
|
||||
})
|
||||
|
||||
it('splits a partial expression into wired and fixed fields', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model, reasoning_effort: flow_input.thinking }"
|
||||
})
|
||||
expect(wiring).toEqual({
|
||||
fields: { model: 'model', reasoning_effort: 'thinking' },
|
||||
fixed: { kind: 'anthropic', resource: '$res:u/admin/claude' }
|
||||
})
|
||||
})
|
||||
|
||||
// The regression this detector exists for: counting flow_input references would read
|
||||
// this as one input carrying the whole provider object, and the composer would write
|
||||
// {kind, resource, model} into an input the expression uses as the model name.
|
||||
it('does not mistake a single-reference partial expression for a whole-object one', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }"
|
||||
})
|
||||
expect(wiring?.whole).toBeUndefined()
|
||||
expect(wiring?.fields).toEqual({ model: 'model' })
|
||||
})
|
||||
|
||||
// The flow editor's JS field commonly holds a parenthesised object, which is how an
|
||||
// author writes one without it reading as a block.
|
||||
it('accepts a parenthesised object expression', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: `({
|
||||
"kind": "anthropic",
|
||||
"resource": "$res:u/admin/anthropic_windmill_codegen",
|
||||
"model": "claude-sonnet-5",
|
||||
"reasoning_effort": flow_input.thinking
|
||||
})`
|
||||
})
|
||||
expect(wiring).toEqual({
|
||||
fields: { reasoning_effort: 'thinking' },
|
||||
fixed: {
|
||||
kind: 'anthropic',
|
||||
resource: '$res:u/admin/anthropic_windmill_codegen',
|
||||
model: 'claude-sonnet-5'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a static provider as entirely fixed', () => {
|
||||
expect(
|
||||
parseProviderTransform({
|
||||
type: 'static',
|
||||
value: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
|
||||
})
|
||||
).toEqual({
|
||||
fields: {},
|
||||
fixed: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['{ ...base, model: flow_input.model }', 'a spread could supply any field'],
|
||||
['{ model: pickModel(flow_input.x) }', 'a call is not classifiable'],
|
||||
['flow_input.model + 1', 'not a bare reference'],
|
||||
['{ model: ', 'unparseable']
|
||||
])('gives up on %s', (expr) => {
|
||||
expect(parseProviderTransform({ type: 'javascript', expr } as any)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentModelWiringInputs', () => {
|
||||
// The button writes `kind` only alongside a resource, since a provider is picked as a
|
||||
// pair. Hiding a kind input it cannot write would leave the run without one.
|
||||
it('keeps a kind input the model button cannot write', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ kind: flow_input.k, "resource": "$res:u/admin/claude", model: flow_input.m })`)
|
||||
])
|
||||
expect(agentModelWiringInputs(wiring)).toEqual(['m'])
|
||||
})
|
||||
|
||||
it('hides a kind input it writes with the resource', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ kind: flow_input.k, resource: flow_input.r, model: flow_input.m })`)
|
||||
])
|
||||
expect(agentModelWiringInputs(wiring)?.sort()).toEqual(['k', 'm', 'r'])
|
||||
})
|
||||
|
||||
// A thinking control is usable whatever the registry knows about the model — a ladder, a
|
||||
// typed token, or why there is neither — so a wired effort is the button's there.
|
||||
it('claims a wired reasoning_effort whatever the provider', () => {
|
||||
const custom = resolveAgentModelWiring([
|
||||
agent(
|
||||
`({ "kind": "customai", "resource": "$res:u/admin/custom", model: flow_input.m, reasoning_effort: flow_input.thinking })`
|
||||
)
|
||||
])
|
||||
expect(agentModelWiringInputs(custom)?.sort()).toEqual(['m', 'thinking'])
|
||||
|
||||
const known = resolveAgentModelWiring([
|
||||
agent(
|
||||
`({ "kind": "openai", "resource": "$res:u/admin/oai", "model": "gpt-4o", reasoning_effort: flow_input.thinking })`
|
||||
)
|
||||
])
|
||||
expect(agentModelWiringInputs(known)).toEqual(['thinking'])
|
||||
})
|
||||
|
||||
// Agents on different fixed models leave the button no model to place the effort on, so
|
||||
// it would say "Pick a model first" with nothing to pick. The modal keeps the input.
|
||||
it('leaves a shared effort to the modal when the agents fix different models', () => {
|
||||
const resource = `"kind": "anthropic", "resource": "$res:u/admin/claude"`
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${resource}, "model": "claude-sonnet-5", reasoning_effort: flow_input.thinking })`),
|
||||
agent(`({ ${resource}, "model": "claude-opus-5", reasoning_effort: flow_input.thinking })`)
|
||||
])
|
||||
expect(wiring?.fields.reasoning_effort).toBe('thinking')
|
||||
expect(agentModelWiringInputs(wiring)).toEqual([])
|
||||
expect(showsModelButton(wiring)).toBe(false)
|
||||
})
|
||||
|
||||
// The same one level up: agents on different providers leave the model menu nothing to
|
||||
// list and no provider to gate a typed id, so the shared model input stays in the modal.
|
||||
it('leaves a shared model to the modal when the agents fix different providers', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ "kind": "openai", "resource": "$res:u/admin/oai", model: flow_input.model })`),
|
||||
agent(
|
||||
`({ "kind": "azure_openai", "resource": "$res:u/admin/azure", model: flow_input.model })`
|
||||
)
|
||||
])
|
||||
expect(wiring?.fields.model).toBe('model')
|
||||
expect(agentModelWiringInputs(wiring)).toEqual([])
|
||||
expect(showsModelButton(wiring)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// The modal is whatever this does not return, so the two cannot disagree about an input.
|
||||
describe('composerOwnedInputs', () => {
|
||||
const wiring = () =>
|
||||
resolveAgentModelWiring([
|
||||
agent(`({ "kind": "openai", "resource": "$res:u/admin/oai", model: flow_input.m })`)
|
||||
])
|
||||
|
||||
it('claims the model wiring and the attachments target together', () => {
|
||||
expect(composerOwnedInputs(wiring(), { name: 'files' })?.sort()).toEqual(['files', 'm'])
|
||||
})
|
||||
|
||||
it('claims the attachments input whatever the workspace can do with it', () => {
|
||||
// No object storage is a state the paperclip shows, not a handover: the modal could
|
||||
// not upload either, and the run would fail fetching a key typed there.
|
||||
expect(composerOwnedInputs(undefined, { name: 'files' })).toEqual(['files'])
|
||||
})
|
||||
|
||||
it('leaves an input no control fits to the modal', () => {
|
||||
expect(composerOwnedInputs(wiring(), undefined)).toEqual(['m'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAgentModelWiring', () => {
|
||||
const fixedResource = `"kind": "anthropic", "resource": "$res:u/admin/claude"`
|
||||
|
||||
it('drives a field every agent reads from the same input', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(
|
||||
`({ ${fixedResource}, "model": "claude-sonnet-5", reasoning_effort: flow_input.thinking })`
|
||||
),
|
||||
agent(
|
||||
`({ ${fixedResource}, "model": "claude-opus-5", reasoning_effort: flow_input.thinking })`
|
||||
)
|
||||
])
|
||||
expect(wiring?.fields).toEqual({ reasoning_effort: 'thinking' })
|
||||
// The agents run different models, so there is no single one to name.
|
||||
expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/admin/claude' })
|
||||
})
|
||||
|
||||
it('drops a field the agents disagree about', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, reasoning_effort: flow_input.thinking })`),
|
||||
agent(`({ ${fixedResource}, reasoning_effort: flow_input.other })`)
|
||||
])
|
||||
expect(wiring?.fields.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
// A nested agent is the parent agent's tool, not a step the reader is talking to: the
|
||||
// graph walks it as a child module, and counting it here would defeat the chat's own
|
||||
// model control (this is the shape of the all-tools example flow).
|
||||
it("ignores an agent carried as another agent's tool", () => {
|
||||
const parent = agent('flow_input.model')
|
||||
;(parent.value as any).tools = [
|
||||
{
|
||||
id: 'summarize',
|
||||
value: {
|
||||
tool_type: 'flowmodule',
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: {
|
||||
provider: { type: 'static', value: { kind: 'anthropic', model: 'claude-sonnet-5' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(resolveAgentModelWiring([parent])).toEqual({
|
||||
whole: 'model',
|
||||
fields: {},
|
||||
fixed: {},
|
||||
someAgentCannotRun: false
|
||||
})
|
||||
})
|
||||
|
||||
it('finds an agent inside a loop or a branch', () => {
|
||||
const inner = agent(`({ ${fixedResource}, model: flow_input.model })`)
|
||||
const loop = {
|
||||
id: 'loop',
|
||||
value: { type: 'forloopflow', modules: [inner] }
|
||||
} as unknown as FlowModule
|
||||
const branch = {
|
||||
id: 'branch',
|
||||
value: { type: 'branchone', default: [], branches: [{ modules: [inner] }] }
|
||||
} as unknown as FlowModule
|
||||
expect(resolveAgentModelWiring([loop])?.fields.model).toBe('model')
|
||||
expect(resolveAgentModelWiring([branch])?.fields.model).toBe('model')
|
||||
})
|
||||
|
||||
// The control writes one flow input; an agent that fixes the field instead never reads
|
||||
// it, so offering the control would move one agent and leave the other where it was.
|
||||
it('does not offer a field one agent wires and another fixes', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, model: flow_input.model })`),
|
||||
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
|
||||
])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
expect(wiring?.fixed.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// Disagreeing about the model is not the same as having no model: the flow runs, on a
|
||||
// different one per agent, and the composer has nothing to fix.
|
||||
it('says nothing about a model the agents merely disagree about', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
|
||||
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
|
||||
])
|
||||
expect(agentModelGap(wiring)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still reports an agent with nothing to call', () => {
|
||||
expect(
|
||||
agentModelGap(resolveAgentModelWiring([agent(`({ "kind": "openai", "model": "" })`)]))
|
||||
).toBe('Pick a provider and model on the AI agent step to use this chat.')
|
||||
})
|
||||
|
||||
// An expression the parser cannot account for could supply anything, so the agents it
|
||||
// belongs to cannot be spoken for either.
|
||||
it("offers nothing when one agent's provider cannot be read", () => {
|
||||
expect(
|
||||
resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, model: flow_input.model })`),
|
||||
agent(`({ ...base, model: flow_input.model })`)
|
||||
])
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
// Disagreement is not the same as absence, but an agent with an empty model still
|
||||
// cannot run, however well its neighbour is configured.
|
||||
it('keeps warning when one agent has no model and another does', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
|
||||
agent(`({ ${fixedResource}, "model": "" })`)
|
||||
])
|
||||
expect(agentModelGap(wiring)).toBe(
|
||||
'Pick a provider and model on the AI agent step to use this chat.'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a flow mixing whole-object and field-by-field wiring', () => {
|
||||
expect(
|
||||
resolveAgentModelWiring([
|
||||
agent('flow_input.provider'),
|
||||
agent(`({ ${fixedResource}, model: flow_input.model })`)
|
||||
])
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withoutRejectedEffort', () => {
|
||||
const wiring = (fields: Record<string, string>, fixed: Record<string, any> = {}) =>
|
||||
({ fields, fixed }) as any
|
||||
|
||||
// The live 400 this guards: Anthropic turns any effort into adaptive thinking, which
|
||||
// Haiku rejects outright ("adaptive thinking is not supported on this model").
|
||||
it('drops an effort the chosen model rejects', () => {
|
||||
const values = { model: 'claude-haiku-4-5-20251001', reasoning_effort: 'high' }
|
||||
expect(
|
||||
withoutRejectedEffort(
|
||||
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
|
||||
values
|
||||
)
|
||||
).toEqual({ model: 'claude-haiku-4-5-20251001', reasoning_effort: '' })
|
||||
})
|
||||
|
||||
it('keeps an effort the model takes', () => {
|
||||
const values = { model: 'claude-sonnet-5', reasoning_effort: 'high' }
|
||||
expect(
|
||||
withoutRejectedEffort(
|
||||
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
|
||||
values
|
||||
)
|
||||
).toBe(values)
|
||||
})
|
||||
|
||||
// Clearing on a guess would override the author's own default.
|
||||
it('leaves the value alone for a family the registry cannot speak for', () => {
|
||||
const values = { model: 'some-model', reasoning_effort: 'high' }
|
||||
expect(
|
||||
withoutRejectedEffort(
|
||||
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'customai' }),
|
||||
values
|
||||
)
|
||||
).toBe(values)
|
||||
})
|
||||
|
||||
// A flow that wires `provider` as one object keeps the effort inside it, so reading
|
||||
// `fields.reasoning_effort` finds nothing and the 400 would go out unchecked.
|
||||
it('clears the effort inside a whole-object provider input', () => {
|
||||
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
|
||||
const values = {
|
||||
provider: {
|
||||
kind: 'anthropic',
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
reasoning_effort: 'high'
|
||||
}
|
||||
}
|
||||
expect(withoutRejectedEffort(wiring, values)).toEqual({
|
||||
provider: {
|
||||
kind: 'anthropic',
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
reasoning_effort: ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a whole-object provider alone when the model takes the effort', () => {
|
||||
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
|
||||
const values = {
|
||||
provider: { kind: 'anthropic', model: 'claude-sonnet-5', reasoning_effort: 'high' }
|
||||
}
|
||||
expect(withoutRejectedEffort(wiring, values)).toBe(values)
|
||||
})
|
||||
|
||||
// A model that reasons can still refuse a particular token, and the provider answers with a
|
||||
// 400 on every turn.
|
||||
it('drops a level or off token a reasoning model does not take', () => {
|
||||
const openai = wiring({ model: 'model', reasoning_effort: 'effort' }, { kind: 'openai' })
|
||||
expect(withoutRejectedEffort(openai, { model: 'gpt-5', effort: 'none' }).effort).toBe('')
|
||||
expect(withoutRejectedEffort(openai, { model: 'gpt-5.1', effort: 'xhigh' }).effort).toBe('')
|
||||
const kept = { model: 'gpt-5.1', effort: 'none' }
|
||||
expect(withoutRejectedEffort(openai, kept)).toBe(kept)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The shape agent chat is usually built in: one agent answers the reader, others do work of
|
||||
* their own in branches. A sub-agent never sees the message, so what it runs on is not a
|
||||
* setting this conversation has.
|
||||
*/
|
||||
describe('agents that do not read the message', () => {
|
||||
const answerer = (provider: string) =>
|
||||
({
|
||||
id: 'answerer',
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: {
|
||||
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
|
||||
provider: { type: 'javascript', expr: provider }
|
||||
}
|
||||
}
|
||||
}) as unknown as FlowModule
|
||||
const subAgent = (provider: string, message = "'critique: ' + results.answerer") =>
|
||||
({
|
||||
id: 'critic',
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: {
|
||||
user_message: { type: 'javascript', expr: message },
|
||||
provider: { type: 'javascript', expr: provider }
|
||||
}
|
||||
}
|
||||
}) as unknown as FlowModule
|
||||
|
||||
const wired = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: flow_input.model })`
|
||||
const fixed = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: 'claude-sonnet-5' })`
|
||||
|
||||
it('keeps the model control when only a sub-agent fixes its own model', () => {
|
||||
const wiring = resolveAgentModelWiring([answerer(wired), subAgent(fixed)])
|
||||
expect(wiring?.fields.model).toBe('model')
|
||||
})
|
||||
|
||||
// Two agents both answering the reader still have to agree: either might be the one
|
||||
// that replies, so a control moving one of them would be a lie about the other.
|
||||
it('still needs agreement among the agents that do read the message', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
answerer(wired),
|
||||
subAgent(fixed, 'flow_input.user_message')
|
||||
])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// Nothing to scope to means the flow is shaped in some way this cannot read, so every
|
||||
// agent counts again rather than none.
|
||||
it('falls back to every agent when none reads the message', () => {
|
||||
const wiring = resolveAgentModelWiring([subAgent(wired), subAgent(fixed)])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// The editor writes the dot form, but an author may hand-edit either. A shape this does
|
||||
// not recognise drops that agent out of the unanimity check it should be part of.
|
||||
it.each([
|
||||
["flow_input['user_message']", 'bracket access'],
|
||||
['flow_input?.user_message', 'optional chaining'],
|
||||
["'Answer politely: ' + flow_input.user_message", 'embedded in a prompt'],
|
||||
['flow_input.user_message + flow_input.tone', 'read alongside another input'],
|
||||
['flow_input.user_message // the message', 'a trailing comment'],
|
||||
['flow_input.user_message\n// why', 'a comment on its own last line']
|
||||
])('treats %s as reading the message', (message) => {
|
||||
const wiring = resolveAgentModelWiring([answerer(wired), subAgent(fixed, message as string)])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['// see flow_input.user_message', 'a mention in a comment'],
|
||||
["'flow_input.user_message'", 'a mention in a string'],
|
||||
['flow_input.user_message_extra', 'a different input with the same prefix']
|
||||
])('does not treat %s as reading the message', (message) => {
|
||||
const wiring = resolveAgentModelWiring([answerer(wired), subAgent(fixed, message as string)])
|
||||
expect(wiring?.fields.model).toBe('model')
|
||||
})
|
||||
})
|
||||
|
||||
// An author annotating their own provider must not lose the model control for it: a
|
||||
// comment beside the expression is not another expression.
|
||||
describe('parseProviderTransform with comments', () => {
|
||||
it.each([
|
||||
['flow_input.provider // the one to use', 'a trailing line comment'],
|
||||
['flow_input.provider /* the one to use */', 'a trailing block comment'],
|
||||
['/* pick one */ flow_input.provider', 'a leading comment']
|
||||
])('reads a whole-object reference despite %s', (expr) => {
|
||||
expect(parseProviderTransform({ type: 'javascript', expr })?.whole).toBe('provider')
|
||||
})
|
||||
|
||||
it('reads an object literal with a comment inside it', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: `({ kind: 'anthropic', /* fixed */ resource: '$res:u/admin/c', model: flow_input.model })`
|
||||
})
|
||||
expect(wiring?.fields.model).toBe('model')
|
||||
})
|
||||
|
||||
// The check exists to reject an expression with something else beside it; a second
|
||||
// expression is still something else.
|
||||
it('still refuses a second expression beside it', () => {
|
||||
expect(
|
||||
parseProviderTransform({ type: 'javascript', expr: 'flow_input.provider, 1' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,441 @@
|
||||
import type { AIProvider, FlowModule, InputTransform } from '$lib/gen'
|
||||
import { explicitOffToken, getReasoningCapability } from '$lib/components/copilot/reasoningRegistry'
|
||||
import { carriedReasoning } from '$lib/components/copilot/chatModelSettings'
|
||||
import { parseExpressionAt } from 'acorn'
|
||||
|
||||
/**
|
||||
* The flow's own AI agent steps, including those inside loops and branches but never one
|
||||
* carried as another agent's tool.
|
||||
*
|
||||
* The graph walks an agent's tools as if they were child steps (flowTree.ts), which is
|
||||
* right for the graph and wrong here: a tool agent's provider belongs to the agent that
|
||||
* calls it, not to the chat. Counting it would let a nested agent's fixed model defeat the
|
||||
* composer's model control on the step the reader is actually talking to.
|
||||
*/
|
||||
function agentSteps(modules: FlowModule[] | undefined): FlowModule[] {
|
||||
const found: FlowModule[] = []
|
||||
const walk = (mods: FlowModule[]) => {
|
||||
for (const module of mods) {
|
||||
const value = module.value as any
|
||||
if (value?.type === 'aiagent') {
|
||||
found.push(module)
|
||||
continue
|
||||
}
|
||||
if (value?.type === 'forloopflow' || value?.type === 'whileloopflow') {
|
||||
walk(value.modules ?? [])
|
||||
} else if (value?.type === 'branchone') {
|
||||
walk(value.default ?? [])
|
||||
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
|
||||
} else if (value?.type === 'branchall') {
|
||||
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(modules ?? [])
|
||||
return found
|
||||
}
|
||||
|
||||
/** Block and line comments removed, so what is left is only what affects the value. */
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* An expression wrapped so acorn will read it: parenthesised, because a leading `{` would
|
||||
* otherwise parse as a block, and on its own line, because a trailing `// comment` would
|
||||
* otherwise swallow the closing paren and make the whole thing unparseable.
|
||||
*/
|
||||
function parenthesised(expr: string): string {
|
||||
return `(\n${expr}\n)`
|
||||
}
|
||||
|
||||
/** The flow input the server requires on a chat-enabled flow, and stores as the message. */
|
||||
const MESSAGE_INPUT = 'user_message'
|
||||
|
||||
/**
|
||||
* The agents the reader is talking to: the ones the chat's message is fed to.
|
||||
*
|
||||
* A flow commonly runs one agent on the message and others on work of their own — a
|
||||
* critic reading `results.x`, a classifier in a branch. Those never see what was typed,
|
||||
* so what they run on is not a setting this conversation has: letting one of them differ
|
||||
* on the model would take the model control away from the agent that does answer.
|
||||
*
|
||||
* The message need not be the whole prompt — an author wraps it in context freely — so
|
||||
* this asks whether the expression reads it at all. A flow where no agent reads it is one
|
||||
* shaped in some way this cannot speak for, and every agent counts again rather than none.
|
||||
*/
|
||||
function chatFacingAgents(modules: FlowModule[] | undefined): FlowModule[] {
|
||||
const agents = agentSteps(modules)
|
||||
const facing = agents.filter((module) => {
|
||||
const transform = (module.value as any).input_transforms?.[MESSAGE_INPUT]
|
||||
return transform?.type === 'javascript' && readsFlowInput(transform.expr, MESSAGE_INPUT)
|
||||
})
|
||||
return facing.length > 0 ? facing : agents
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an expression reads `flow_input.<name>` anywhere in it.
|
||||
*
|
||||
* Parsed rather than matched: the author may write `flow_input['user_message']` as readily
|
||||
* as the dot form the editor emits, and a mention inside a comment or a string is not a
|
||||
* read. Reading two inputs is still a read of each, which is why this is not the question
|
||||
* "which single input feeds a field" that the composer asks of a wired field.
|
||||
*/
|
||||
function readsFlowInput(expr: string, name: string): boolean {
|
||||
let root: unknown
|
||||
try {
|
||||
root = parseExpressionAt(parenthesised(expr), 0, { ecmaVersion: 'latest' })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
let found = false
|
||||
const visit = (node: any) => {
|
||||
if (found || !node || typeof node !== 'object') return
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(visit)
|
||||
return
|
||||
}
|
||||
if (flowInputName(node) === name) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'type' || key === 'start' || key === 'end') continue
|
||||
visit(node[key])
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
return found
|
||||
}
|
||||
|
||||
/** A provider value as the agent stores it. */
|
||||
export type AgentModel = { kind?: string; model?: string; reasoning_effort?: string }
|
||||
|
||||
/** The provider fields the composer can read or drive. */
|
||||
const PROVIDER_FIELDS = ['kind', 'resource', 'model', 'reasoning_effort'] as const
|
||||
export type ProviderField = (typeof PROVIDER_FIELDS)[number]
|
||||
|
||||
/**
|
||||
* How an AI agent step's `provider` is supplied, field by field.
|
||||
*
|
||||
* The agent takes one `provider` object, so an author who wants the chat to choose only
|
||||
* the model writes the rest as literals around it:
|
||||
*
|
||||
* { kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }
|
||||
*
|
||||
* `fields` names the flow input behind each field the author exposed, `fixed` holds the
|
||||
* literals, and `whole` covers the plain `flow_input.x` case where one input carries the
|
||||
* entire object. Reading them apart is what lets the composer offer exactly the knobs the
|
||||
* flow exposed — and stops a partial expression from being mistaken for a whole-object
|
||||
* one, which would write a provider object into an input the flow reads as a model name.
|
||||
*/
|
||||
export type AgentModelWiring = {
|
||||
whole?: string
|
||||
fields: Partial<Record<ProviderField, string>>
|
||||
fixed: Partial<Record<ProviderField, any>>
|
||||
/**
|
||||
* One of the agents names no resource or no model of its own and no flow input feeds
|
||||
* it, so that agent's run fails whatever the others do. Held apart from the fields,
|
||||
* which describe what the composer may offer.
|
||||
*/
|
||||
someAgentCannotRun?: boolean
|
||||
}
|
||||
|
||||
/** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */
|
||||
function flowInputName(node: any): string | undefined {
|
||||
const member = node?.type === 'ChainExpression' ? node.expression : node
|
||||
if (member?.type !== 'MemberExpression') return undefined
|
||||
if (member.object?.type !== 'Identifier' || member.object.name !== 'flow_input') return undefined
|
||||
if (!member.computed && member.property?.type === 'Identifier') return member.property.name
|
||||
if (member.computed && member.property?.type === 'Literal') {
|
||||
return typeof member.property.value === 'string' ? member.property.value : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** A property's name, for the plain `key:` and `'key':` forms only. */
|
||||
function propertyKey(property: any): string | undefined {
|
||||
if (property?.type !== 'Property' || property.computed) return undefined
|
||||
if (property.key?.type === 'Identifier') return property.key.name
|
||||
if (property.key?.type === 'Literal' && typeof property.key.value === 'string') {
|
||||
return property.key.value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `provider` input transform. Anything this cannot account for in full returns
|
||||
* undefined rather than a guess: the composer then leaves the field alone instead of
|
||||
* writing into an expression it does not understand.
|
||||
*/
|
||||
export function parseProviderTransform(
|
||||
transform: InputTransform | undefined
|
||||
): AgentModelWiring | undefined {
|
||||
if (transform?.type === 'static') {
|
||||
const value = transform.value
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const fixed: AgentModelWiring['fixed'] = {}
|
||||
for (const field of PROVIDER_FIELDS) {
|
||||
if (value[field] !== undefined) fixed[field] = value[field]
|
||||
}
|
||||
return { fields: {}, fixed }
|
||||
}
|
||||
if (transform?.type !== 'javascript') return undefined
|
||||
|
||||
// The author's own text may already be wrapped, so any balanced surround is fine — what
|
||||
// the span check rejects is an expression with something else beside it.
|
||||
const source = parenthesised(transform.expr)
|
||||
let node: any
|
||||
try {
|
||||
node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
// A comment beside the expression is not another expression: the author annotating their
|
||||
// own provider must not cost them the model control. Dropped before the check so what
|
||||
// remains is only what would change the value — and so a paren inside a comment is not
|
||||
// counted as one of the wrapping pair.
|
||||
const before = withoutComments(source.slice(0, node.start))
|
||||
const after = withoutComments(source.slice(node.end))
|
||||
if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined
|
||||
if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined
|
||||
|
||||
const whole = flowInputName(node)
|
||||
if (whole) return { whole, fields: {}, fixed: {} }
|
||||
if (node.type !== 'ObjectExpression') return undefined
|
||||
|
||||
const fields: AgentModelWiring['fields'] = {}
|
||||
const fixed: AgentModelWiring['fixed'] = {}
|
||||
for (const property of node.properties) {
|
||||
const key = propertyKey(property)
|
||||
// A spread or a computed key could supply any field, so nothing here is knowable.
|
||||
if (!key) return undefined
|
||||
if (!(PROVIDER_FIELDS as readonly string[]).includes(key)) continue
|
||||
const name = flowInputName(property.value)
|
||||
if (name) {
|
||||
fields[key as ProviderField] = name
|
||||
} else if (property.value?.type === 'Literal') {
|
||||
fixed[key as ProviderField] = property.value.value
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return { fields, fixed }
|
||||
}
|
||||
|
||||
/** How one agent supplies a provider field: from an input, as a literal, or not at all. */
|
||||
type FieldSupply =
|
||||
| { kind: 'wired'; name: string }
|
||||
| { kind: 'fixed'; value: any }
|
||||
| { kind: 'absent' }
|
||||
|
||||
/** Whether one agent supplies a field with nothing usable: no input, and no literal. */
|
||||
function agentFieldEmpty(wiring: AgentModelWiring, field: ProviderField): boolean {
|
||||
if (wiring.fields[field] !== undefined) return false
|
||||
const value = wiring.fixed[field]
|
||||
return value === undefined || value === ''
|
||||
}
|
||||
|
||||
function fieldSupply(wiring: AgentModelWiring, field: ProviderField): FieldSupply {
|
||||
const name = wiring.fields[field]
|
||||
if (name !== undefined) return { kind: 'wired', name }
|
||||
const value = wiring.fixed[field]
|
||||
if (value !== undefined) return { kind: 'fixed', value }
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider wiring the chat can act on, across every AI agent in the flow.
|
||||
*
|
||||
* With several agents a field is drivable when they agree on it: one flow input feeding
|
||||
* it, or one literal fixing it. Where they disagree there is no single value to show or
|
||||
* write, so that field is dropped and the others still work. A flow mixing whole-object
|
||||
* and field-by-field wiring is ambiguous throughout and yields nothing.
|
||||
*/
|
||||
export function resolveAgentModelWiring(
|
||||
modules: FlowModule[] | undefined
|
||||
): AgentModelWiring | undefined {
|
||||
const agents = chatFacingAgents(modules)
|
||||
const parsed = agents.map((agent) =>
|
||||
parseProviderTransform((agent.value as any).input_transforms?.['provider'])
|
||||
)
|
||||
if (parsed.length === 0) return undefined
|
||||
// An agent whose provider cannot be read is an agent the composer cannot speak for:
|
||||
// dropping it would let the rest declare a control that governs only some of them.
|
||||
if (parsed.some((wiring) => wiring === undefined)) return undefined
|
||||
const wirings = parsed as AgentModelWiring[]
|
||||
// Whether any single agent has nothing to call, which stays true however the others
|
||||
// are wired — the gap message is about that agent, not about their agreement.
|
||||
const someAgentCannotRun = wirings.some(
|
||||
(wiring) =>
|
||||
!wiring.whole && (agentFieldEmpty(wiring, 'resource') || agentFieldEmpty(wiring, 'model'))
|
||||
)
|
||||
if (wirings.length === 1) return { ...wirings[0], someAgentCannotRun }
|
||||
|
||||
const wholes = new Set(wirings.map((w) => w.whole))
|
||||
if (wholes.size === 1 && !wholes.has(undefined)) {
|
||||
return { whole: [...wholes][0], fields: {}, fixed: {}, someAgentCannotRun }
|
||||
}
|
||||
if (wirings.some((w) => w.whole !== undefined)) return undefined
|
||||
|
||||
const fields: AgentModelWiring['fields'] = {}
|
||||
const fixed: AgentModelWiring['fixed'] = {}
|
||||
for (const field of PROVIDER_FIELDS) {
|
||||
// Every agent has to supply the field the same way for the composer to speak for
|
||||
// them all. One wired name among agents that otherwise fix it is not agreement:
|
||||
// the control would move that one agent and leave the others where they are.
|
||||
const supplies = new Set(wirings.map((w) => JSON.stringify(fieldSupply(w, field))))
|
||||
// Disagreement leaves the field neither editable nor known: a control offered here
|
||||
// would govern one agent while the rest ran on something else.
|
||||
if (supplies.size > 1) continue
|
||||
const supply: FieldSupply = JSON.parse([...supplies][0])
|
||||
if (supply.kind === 'wired') fields[field] = supply.name
|
||||
else if (supply.kind === 'fixed') fixed[field] = supply.value
|
||||
}
|
||||
return { fields, fixed, someAgentCannotRun }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the chat cannot run, when the agent's own provider is incomplete.
|
||||
*
|
||||
* A freshly added agent carries `{ kind: 'openai', model: '', resource: '' }`, so it names
|
||||
* a provider kind while having nothing to call — the run fails and the chat can do nothing
|
||||
* about it, because no flow input feeds either field. Saying so beats a dead model button.
|
||||
* A field the flow exposes is never a gap: the reader picks it in the composer.
|
||||
*/
|
||||
export function agentModelGap(wiring: AgentModelWiring | undefined): string | undefined {
|
||||
// No agent, several of them, or an expression we cannot read: not ours to judge.
|
||||
if (!wiring || wiring.whole) return undefined
|
||||
// Asked of each agent rather than of what they agree on: agents that merely disagree
|
||||
// about the model all have one, and the message would be false — while an agent with
|
||||
// an empty model still cannot run, however well the others are configured.
|
||||
return wiring.someAgentCannotRun
|
||||
? 'Pick a provider and model on the AI agent step to use this chat.'
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider fields the model button edits. Everything else wired to an input is left to
|
||||
* the Configure-inputs modal, and the button draws no control for it.
|
||||
*
|
||||
* A field is the button's only where its control can be used, which each field makes depend
|
||||
* on the one before it. An input promoted without a usable control is one nothing can edit.
|
||||
* - `resource` is always usable: the submenu lists the workspace's AI resources.
|
||||
* - `kind` is written only alongside a resource, since a provider is picked as a pair. Wired
|
||||
* with the resource fixed, the button has nothing to write it with.
|
||||
* - `model` needs a provider to list models for and to gate the typed entry: a wired resource
|
||||
* or kind, or one fixed kind. Agents that fix different kinds leave none.
|
||||
* - `reasoning_effort` needs a model to place the effort on: a driven model or one fixed
|
||||
* model. Agents that fix different models leave none.
|
||||
*/
|
||||
export function composerDrivenFields(wiring: AgentModelWiring): Set<ProviderField> {
|
||||
if (wiring.whole) return new Set(PROVIDER_FIELDS)
|
||||
const wired = (field: ProviderField) => wiring.fields[field] !== undefined
|
||||
const driven = new Set<ProviderField>()
|
||||
if (wired('resource')) {
|
||||
driven.add('resource')
|
||||
if (wired('kind')) driven.add('kind')
|
||||
}
|
||||
const providerKnown = wired('resource') || wired('kind') || fixedOne(wiring, 'kind')
|
||||
if (wired('model') && providerKnown) driven.add('model')
|
||||
const modelKnown = driven.has('model') || fixedOne(wiring, 'model')
|
||||
if (wired('reasoning_effort') && modelKnown) driven.add('reasoning_effort')
|
||||
return driven
|
||||
}
|
||||
|
||||
/** Whether every agent fixes the field to the same non-empty literal. */
|
||||
function fixedOne(wiring: AgentModelWiring, field: ProviderField): boolean {
|
||||
const value = wiring.fixed[field]
|
||||
return value !== undefined && value !== ''
|
||||
}
|
||||
|
||||
/** The flow inputs the model button writes, so the modal does not ask for them twice. */
|
||||
export function agentModelWiringInputs(wiring: AgentModelWiring | undefined): string[] {
|
||||
if (!wiring) return []
|
||||
if (wiring.whole) return [wiring.whole]
|
||||
return [...composerDrivenFields(wiring)]
|
||||
.map((field) => wiring.fields[field])
|
||||
.filter((name): name is string => !!name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the composer draws a model button at all: something to write, or a fixed model to
|
||||
* name. Agents that fix different models leave neither.
|
||||
*/
|
||||
export function showsModelButton(wiring: AgentModelWiring | undefined): boolean {
|
||||
if (!wiring) return false
|
||||
return agentModelWiringInputs(wiring).length > 0 || fixedOne(wiring, 'model')
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow inputs the composer edits, and therefore the ones the Configure-inputs modal must
|
||||
* not ask for. The modal is whatever is left, so this is the single answer to "who edits
|
||||
* this" rather than a list kept in step with the controls that render.
|
||||
*
|
||||
* Only the shape of the flow decides it. Whether a control can act *right now* — no rules
|
||||
* for a provider's thinking levels, say — is a state that control shows, not a reason to
|
||||
* hand the input to an editor that would be no more able. The attachments target is the
|
||||
* composer's other owner: the input its paperclip uploads into, where the flow has one.
|
||||
*/
|
||||
export function composerOwnedInputs(
|
||||
wiring: AgentModelWiring | undefined,
|
||||
attachmentsTarget: { name: string } | undefined
|
||||
): string[] {
|
||||
return [...agentModelWiringInputs(wiring), ...(attachmentsTarget ? [attachmentsTarget.name] : [])]
|
||||
}
|
||||
|
||||
/**
|
||||
* The run's inputs with a reasoning effort the chosen model cannot take removed.
|
||||
*
|
||||
* The model button reconciles the two when the reader switches model, which covers the only
|
||||
* way the composer can put them out of step. It is not the only way they get out of step:
|
||||
* a value stored from an earlier visit, a default the flow author wrote, or a model chosen
|
||||
* before the effort was, all arrive already mismatched — and the provider answers a
|
||||
* mismatch with a 400 that names neither input ("adaptive thinking is not supported on this
|
||||
* model"). Checked here, where the run's arguments are settled, so every route is covered.
|
||||
*
|
||||
* Only where the registry positively knows the model rejects it. An unknown family keeps
|
||||
* whatever the author wrote: dropping a value on a guess would override their own default.
|
||||
*/
|
||||
export function withoutRejectedEffort(
|
||||
wiring: AgentModelWiring | undefined,
|
||||
values: Record<string, any>
|
||||
): Record<string, any> {
|
||||
if (!wiring) return values
|
||||
|
||||
// One input carrying the whole provider object: the three fields are read from it and
|
||||
// the effort is cleared inside it, since that is where the agent will look for them.
|
||||
if (wiring.whole) {
|
||||
const provider = values[wiring.whole]
|
||||
if (!provider || typeof provider !== 'object') return values
|
||||
if (!rejectsEffort(provider.kind, provider.model, provider.reasoning_effort)) return values
|
||||
return { ...values, [wiring.whole]: { ...provider, reasoning_effort: '' } }
|
||||
}
|
||||
|
||||
const effortInput = wiring.fields.reasoning_effort
|
||||
if (!effortInput) return values
|
||||
const kindInput = wiring.fields.kind
|
||||
const modelInput = wiring.fields.model
|
||||
const rejected = rejectsEffort(
|
||||
kindInput ? values[kindInput] : wiring.fixed.kind,
|
||||
modelInput ? values[modelInput] : wiring.fixed.model,
|
||||
values[effortInput]
|
||||
)
|
||||
return rejected ? { ...values, [effortInput]: '' } : values
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the registry positively says this model will not take this effort: no reasoning at
|
||||
* all, a level it does not have (`xhigh` on `gpt-5.1`), or an off token it cannot honour
|
||||
* (`none` on `gpt-5`). The same rule the button applies when the model changes.
|
||||
*/
|
||||
function rejectsEffort(provider: unknown, model: unknown, effort: unknown): boolean {
|
||||
if (typeof effort !== 'string' || effort === '') return false
|
||||
if (typeof provider !== 'string' || typeof model !== 'string' || !provider || !model) {
|
||||
return false
|
||||
}
|
||||
const capability = getReasoningCapability(provider as AIProvider, model)
|
||||
if (!capability.known) return false
|
||||
const offToken = explicitOffToken(provider as AIProvider, model)
|
||||
return carriedReasoning(effort, offToken, capability) === undefined
|
||||
}
|
||||
@@ -705,6 +705,7 @@
|
||||
path={flow?.path ?? ''}
|
||||
description={flow?.description}
|
||||
inputSchema={flow?.schema}
|
||||
flowModules={flow?.value?.modules}
|
||||
wideLayout
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -102,15 +102,26 @@ tool, `websearch` for web search.
|
||||
}
|
||||
```
|
||||
|
||||
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
|
||||
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
|
||||
"resource": "$res:<path>", "model": <model id> }`. 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
|
||||
{
|
||||
@@ -119,8 +130,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" },
|
||||
@@ -133,6 +144,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
|
||||
|
||||
@@ -133,15 +133,26 @@ tool, \`websearch\` for web search.
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
"resource": "$res:<path>", "model": <model id> }\`. 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
|
||||
{
|
||||
@@ -150,8 +161,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" },
|
||||
@@ -164,6 +175,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
|
||||
|
||||
@@ -190,15 +190,26 @@ tool, `websearch` for web search.
|
||||
}
|
||||
```
|
||||
|
||||
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
|
||||
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
|
||||
"resource": "$res:<path>", "model": <model id> }`. 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
|
||||
{
|
||||
@@ -207,8 +218,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" },
|
||||
@@ -221,6 +232,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
|
||||
|
||||
@@ -102,15 +102,26 @@ tool, `websearch` for web search.
|
||||
}
|
||||
```
|
||||
|
||||
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
|
||||
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
|
||||
"resource": "$res:<path>", "model": <model id> }`. 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
|
||||
{
|
||||
@@ -119,8 +130,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" },
|
||||
@@ -133,6 +144,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
|
||||
|
||||
Reference in New Issue
Block a user