Default model: let the picker choose a reasoning effort
The header switcher only offered provider and model, so catalog efforts a brand-new Session could inherit were reachable solely by typing effort into the Eval and Execution forms. Rows whose catalog entry advertises efforts now expand a second tier — the model default plus each effort, with descriptions and a check on the route in effect — the switch validates the effort against the catalog, treats an effort-only change as a real switch, and forwards the branded id to selectModel. Docs name the re-pointing that dee76b1 added.
This commit is contained in:
@@ -14,7 +14,7 @@ import { RoleModelSchema } from '../policy.ts'
|
||||
|
||||
/** Why a default-model switch was refused, localized by the panel through `defaultModelError.<reason>`. */
|
||||
export class DefaultModelError extends Error {
|
||||
constructor(readonly reason: 'unknownProvider' | 'unknownModel' | 'unchanged' | 'unsaved', detail?: string) {
|
||||
constructor(readonly reason: 'unknownProvider' | 'unknownModel' | 'unknownEffort' | 'unchanged' | 'unsaved', detail?: string) {
|
||||
super(detail ?? reason)
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,14 @@ export async function switchDefaultModel(ctx: Context, masterId: string, input:
|
||||
const wanted = RoleModelSchema.parse(input)
|
||||
const catalog = await ctx.sessionController.modelCatalog()
|
||||
checked(catalog, wanted.provider, wanted.model)
|
||||
// The picker sends provider and model only; the Controller resolves the effort, so effort never makes a route "changed".
|
||||
const entry = catalog.groups.find(group => group.id === wanted.provider)?.models.find(model => model.id === wanted.model)
|
||||
if (wanted.reasoningEffort && !entry?.reasoning?.efforts.some(effort => effort.id === wanted.reasoningEffort)) {
|
||||
throw new DefaultModelError('unknownEffort', `Model "${wanted.provider}/${wanted.model}" does not offer effort "${wanted.reasoningEffort}"`)
|
||||
}
|
||||
// The Controller resolves an implicit effort to the model's default, so compare against the effort the switch will settle on.
|
||||
const predicted = wanted.reasoningEffort ?? entry?.reasoning?.defaultEffort
|
||||
const unchanged = catalog.default.provider === wanted.provider && catalog.default.model === wanted.model
|
||||
&& (catalog.default.reasoningEffort ?? '') === (predicted ?? '')
|
||||
if (unchanged) throw new DefaultModelError('unchanged')
|
||||
// The Controller resolves the call config, so the selection it accepts may carry an effort the caller left implicit.
|
||||
const { selected } = await ctx.sessionController.selectModel({ sessionId: masterId as SessionId, provider: wanted.provider,
|
||||
|
||||
@@ -389,3 +389,4 @@ a.dt-btn, a.dt-btn:hover { text-decoration: none; }
|
||||
.dt-model-option:hover:not(:disabled), .dt-model-option:focus-visible { background: var(--dsw-alias-bg-layer-1); }
|
||||
.dt-model-option[aria-selected='true'] { color: var(--dsw-alias-brand-primary); font-weight: 600; }
|
||||
.dt-model-picker-error { color: var(--dsw-alias-state-error-primary); font-size: 13px; }
|
||||
.dt-model-efforts { position: static; max-height: none; min-width: 0; border: 0; box-shadow: none; background: transparent; padding: 0 0 2px 14px; z-index: auto; }
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* Header-level default-model switcher: one dropdown that changes the DSH deployment default for every new Session.
|
||||
*
|
||||
* The list comes from `GET models` — the same catalog the DSH picker renders — so an operator switches between exactly the
|
||||
* routes a new Executor or Assessor Session could inherit, and rejects come back as localized reasons.
|
||||
* routes a new Executor or Assessor Session could inherit, and rejects come back as localized reasons. Models whose catalog
|
||||
* entry advertises reasoning efforts expand into a second tier of effort choices; the tier's first entry switches the model
|
||||
* without an explicit effort, which is exactly what DSH resolves when a Session picks the model bare.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { ApiError, usePoll, type ApiResponse } from './api.ts'
|
||||
@@ -13,16 +15,19 @@ import type { T } from './text.ts'
|
||||
export type DefaultModelCatalog = ApiResponse<'GET models'>
|
||||
|
||||
/** Render a selection the way the operator reads one elsewhere in the panel. */
|
||||
const selection = (model: { provider: string; model: string }): string => `${model.provider} · ${model.model}`
|
||||
const selection = (model: { provider: string; model: string; reasoningEffort?: string }): string =>
|
||||
`${model.provider} · ${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ''}`
|
||||
|
||||
export function DefaultModelPicker() {
|
||||
const { t, call } = usePanel()
|
||||
const { reload: reloadData } = usePanelData()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string>()
|
||||
const { data: catalog, error, reload } = usePoll(() => call('GET models'), [], 30_000)
|
||||
const pick = useAction(async (provider: string, model: string) => {
|
||||
const pick = useAction(async (provider: string, model: string, effort?: string) => {
|
||||
setOpen(false)
|
||||
await call('PUT default-model', { body: { provider, model } })
|
||||
setExpanded(undefined)
|
||||
await call('PUT default-model', { body: { provider, model, ...effort ? { reasoningEffort: effort } : {} } })
|
||||
await reload()
|
||||
await reloadData()
|
||||
}, { failure: failure => defaultModelRefusal(t, failure) })
|
||||
@@ -30,22 +35,46 @@ export function DefaultModelPicker() {
|
||||
if (!catalog) return <span className="dt-model-picker dt-hint">{t('loading')}</span>
|
||||
const current = catalog.default
|
||||
const options = catalog.groups.flatMap(group => group.models.map(model =>
|
||||
({ provider: group.id, model: model.id, text: `${group.name} · ${model.name}` })))
|
||||
({ provider: group.id, model, text: `${group.name} · ${model.name}` })))
|
||||
return <div className="dt-model-picker">
|
||||
<button type="button" className="dt-btn dt-btn-outline dt-btn-sm" aria-haspopup="listbox" aria-expanded={open}
|
||||
title={t('defaultModelQuickTip')} disabled={pick.busy} onClick={() => setOpen(value => !value)}>
|
||||
<Icon name="settings" size={14} />{t('defaultModelQuick')}:{selection(current)}
|
||||
</button>
|
||||
{open && <ul className="dt-model-menu" role="listbox" aria-label={t('defaultModelQuick')}>
|
||||
{options.map(option => <li key={`${option.provider}/${option.model}`}>
|
||||
<button type="button" role="option" className="dt-model-option" disabled={pick.busy}
|
||||
aria-selected={option.provider === current.provider && option.model === current.model}
|
||||
onClick={() => {
|
||||
// Picking the current default would be refused by the host; just close, so a mis-click never toasts.
|
||||
if (option.provider === current.provider && option.model === current.model) { setOpen(false); return }
|
||||
void pick.run(option.provider, option.model)
|
||||
}}>{option.text}</button>
|
||||
</li>)}
|
||||
{options.map(option => {
|
||||
const key = `${option.provider}/${option.model.id}`
|
||||
const isCurrent = option.provider === current.provider && option.model.id === current.model
|
||||
const efforts = option.model.reasoning?.efforts ?? []
|
||||
return <li key={key}>
|
||||
<button type="button" role="option" className="dt-model-option" disabled={pick.busy} aria-selected={isCurrent}
|
||||
aria-expanded={efforts.length ? expanded === key : undefined}
|
||||
// A model without efforts switches on its own row; one with efforts only expands, so the effort never stays ambiguous.
|
||||
onClick={() => {
|
||||
if (efforts.length) { setExpanded(value => value === key ? undefined : key); return }
|
||||
// Picking the current default would be refused by the host; just close, so a mis-click never toasts.
|
||||
if (isCurrent) setOpen(false)
|
||||
else void pick.run(option.provider, option.model.id)
|
||||
}}>
|
||||
{option.text}{efforts.length ? ' ›' : ''}
|
||||
</button>
|
||||
{expanded === key && efforts.length > 0 && <ul className="dt-model-menu dt-model-efforts" aria-label={t('defaultModelEffortTitle')}>
|
||||
<li>
|
||||
<button type="button" className="dt-model-option" disabled={pick.busy} onClick={() => {
|
||||
// The bare switch settles on the model default, so a mis-click on the route already in effect just closes.
|
||||
if (isCurrent && !current.reasoningEffort) setOpen(false)
|
||||
else void pick.run(option.provider, option.model.id)
|
||||
}}>{t('defaultModelEffortDefault')}{isCurrent && !current.reasoningEffort ? ' ✓' : ''}</button>
|
||||
</li>
|
||||
{efforts.map(effort => <li key={effort.id}>
|
||||
<button type="button" className="dt-model-option" disabled={pick.busy} title={effort.description} onClick={() => {
|
||||
if (isCurrent && current.reasoningEffort === effort.id) setOpen(false)
|
||||
else void pick.run(option.provider, option.model.id, effort.id)
|
||||
}}>{effort.name}{effort.description ? ` · ${effort.description}` : ''}{isCurrent && current.reasoningEffort === effort.id ? ' ✓' : ''}</button>
|
||||
</li>)}
|
||||
</ul>}
|
||||
</li>
|
||||
})}
|
||||
</ul>}
|
||||
</div>
|
||||
}
|
||||
@@ -55,5 +84,5 @@ export function defaultModelRefusal(t: T, failure: unknown): string {
|
||||
const code = failure instanceof ApiError ? failure.details.code : undefined
|
||||
const reason = code?.startsWith('defaultModel.') ? code.slice('defaultModel.'.length) : undefined
|
||||
const text = failure instanceof Error ? failure.message : String(failure)
|
||||
return reason ? t(`defaultModelError.${reason as 'unknownProvider' | 'unknownModel' | 'unchanged' | 'unsaved'}`) : text
|
||||
return reason ? t(`defaultModelError.${reason as 'unknownProvider' | 'unknownModel' | 'unknownEffort' | 'unchanged' | 'unsaved'}`) : text
|
||||
}
|
||||
|
||||
@@ -424,9 +424,12 @@ const TEXT = {
|
||||
modelCatalogError: ['无法加载模型建议,仍可填写已知的服务商和模型:{error}', 'Model suggestions are unavailable; known Provider and Model names still work: {error}'],
|
||||
defaultModelQuick: ['默认模型', 'Default model'],
|
||||
defaultModelQuickTip: ['切换后,之后新建的执行器与研判 Session 都继承这个默认模型;已有 Session 保持原选择。', 'Switching changes what new Executor and Assessor Sessions inherit; existing Sessions keep their choice.'],
|
||||
defaultModelEffortTitle: ['思考强度', 'Reasoning effort'],
|
||||
defaultModelEffortDefault: ['模型默认', 'Model default'],
|
||||
'defaultModelError.load': ['无法读取默认模型列表', 'Cannot load the default-model list'],
|
||||
'defaultModelError.unknownProvider': ['这个服务商当前没有可用模型。', 'That Provider has no available models right now.'],
|
||||
'defaultModelError.unknownModel': ['这个服务商没有提供该模型。', 'That Provider does not offer that model.'],
|
||||
'defaultModelError.unknownEffort': ['这个模型不支持该思考强度。', 'That model does not offer that reasoning effort.'],
|
||||
'defaultModelError.unchanged': ['已经是默认模型。', 'That is already the default.'],
|
||||
'defaultModelError.unsaved': ['Session 已切换,但 DSH 没有保存成新的默认模型;请重试或检查配置编辑权限。', 'The Session switched, but DSH did not save the new default; retry or check the configuration editor.'],
|
||||
historyIntro: ['每次保存记一条;改变策略版本的保存(接单规则、各来源上的策略、接单模式)通知进行中的工单。', 'Each save is one entry; saves that change the Policy version (rules, the Policy on each Source, accept mode) notify open Tickets.'],
|
||||
|
||||
Reference in New Issue
Block a user