Files
windmill/frontend/src/lib/components/copilot/chatModelSettings.ts
T
GuilhemandClaude Fable 5.1 189793c2e4 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>
2026-09-17 09:59:05 +02:00

220 lines
9.5 KiB
TypeScript

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`
}