feat: 添加 model/provider 层改进

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-22 22:38:10 +08:00
parent d208855f07
commit 23bb09d240
13 changed files with 689 additions and 472 deletions

View File

@@ -1,21 +1,21 @@
import capitalize from 'lodash-es/capitalize.js'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { has1mContext } from '../utils/context.js'
import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'
import capitalize from 'lodash-es/capitalize.js';
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { has1mContext } from '../utils/context.js';
import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
} from 'src/services/analytics/index.js';
import {
FAST_MODE_MODEL_DISPLAY,
isFastModeAvailable,
isFastModeCooldown,
isFastModeEnabled,
} from 'src/utils/fastMode.js'
import { Box, Text } from '@anthropic/ink'
import { useKeybindings } from '../keybindings/useKeybinding.js'
import { useAppState, useSetAppState } from '../state/AppState.js'
} from 'src/utils/fastMode.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import { useAppState, useSetAppState } from '../state/AppState.js';
import {
convertEffortValueToLevel,
type EffortLevel,
@@ -24,42 +24,39 @@ import {
modelSupportsMaxEffort,
resolvePickerEffortPersistence,
toPersistableEffort,
} from '../utils/effort.js'
} from '../utils/effort.js';
import {
getDefaultMainLoopModel,
type ModelSetting,
modelDisplayString,
parseUserSpecifiedModel,
} from '../utils/model/model.js'
import { getModelOptions } from '../utils/model/modelOptions.js'
import {
getSettingsForSource,
updateSettingsForSource,
} from '../utils/settings/settings.js'
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
import { Select } from './CustomSelect/index.js'
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'
import { effortLevelToSymbol } from './EffortIndicator.js'
} from '../utils/model/model.js';
import { getModelOptions } from '../utils/model/modelOptions.js';
import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js';
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
import { Select } from './CustomSelect/index.js';
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink';
import { effortLevelToSymbol } from './EffortIndicator.js';
export type Props = {
initial: string | null
sessionModel?: ModelSetting
onSelect: (model: string | null, effort: EffortLevel | undefined) => void
onCancel?: () => void
isStandaloneCommand?: boolean
showFastModeNotice?: boolean
initial: string | null;
sessionModel?: ModelSetting;
onSelect: (model: string | null, effort: EffortLevel | undefined) => void;
onCancel?: () => void;
isStandaloneCommand?: boolean;
showFastModeNotice?: boolean;
/** Overrides the dim header line below "Select model". */
headerText?: string
headerText?: string;
/**
* When true, skip writing effortLevel to userSettings on selection.
* Used by the assistant installer wizard where the model choice is
* project-scoped (written to the assistant's .claude/settings.json via
* install.ts) and should not leak to the user's global ~/.claude/settings.
*/
skipSettingsWrite?: boolean
}
skipSettingsWrite?: boolean;
};
const NO_PREFERENCE = '__NO_PREFERENCE__'
const NO_PREFERENCE = '__NO_PREFERENCE__';
export function ModelPicker({
initial,
@@ -71,49 +68,44 @@ export function ModelPicker({
headerText,
skipSettingsWrite,
}: Props): React.ReactNode {
const setAppState = useSetAppState()
const exitState = useExitOnCtrlCDWithKeybindings()
const maxVisible = 10
const setAppState = useSetAppState();
const exitState = useExitOnCtrlCDWithKeybindings();
const maxVisible = 10;
const initialValue = initial === null ? NO_PREFERENCE : initial
const [focusedValue, setFocusedValue] = useState<string | undefined>(
initialValue,
)
const initialValue = initial === null ? NO_PREFERENCE : initial;
const [focusedValue, setFocusedValue] = useState<string | undefined>(initialValue);
const isFastMode = useAppState(s =>
isFastModeEnabled() ? s.fastMode : false,
)
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
const [marked1MValues, setMarked1MValues] = useState<Set<string>>(
() => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : [])
)
() => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : []),
);
const handleToggle1M = useCallback(() => {
if (!focusedValue || focusedValue === NO_PREFERENCE) return
if (!focusedValue || focusedValue === NO_PREFERENCE) return;
// Key on the base value so lookups in handleSelect / is1MMarked match the
// initializer — predefined 1M options arrive with a `[1m]` suffix in
// `focusedValue`, which would diverge from the base-value key set.
const baseKey = focusedValue.replace(/\[1m\]/i, '');
setMarked1MValues(prev => {
const next = new Set(prev)
if (next.has(focusedValue)) {
next.delete(focusedValue)
const next = new Set(prev);
if (next.has(baseKey)) {
next.delete(baseKey);
} else {
next.add(focusedValue)
next.add(baseKey);
}
return next
})
}, [focusedValue])
return next;
});
}, [focusedValue]);
const [hasToggledEffort, setHasToggledEffort] = useState(false)
const effortValue = useAppState(s => s.effortValue)
const [hasToggledEffort, setHasToggledEffort] = useState(false);
const effortValue = useAppState(s => s.effortValue);
const [effort, setEffort] = useState<EffortLevel | undefined>(
effortValue !== undefined
? convertEffortValueToLevel(effortValue)
: undefined,
)
effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined,
);
// Memoize all derived values to prevent re-renders
const modelOptions = useMemo(
() => getModelOptions(isFastMode ?? false),
[isFastMode],
)
const modelOptions = useMemo(() => getModelOptions(isFastMode ?? false), [isFastMode]);
// Ensure the initial value is in the options list
// This handles edge cases where the user's current model (e.g., 'haiku' for 3P users)
@@ -127,10 +119,10 @@ export function ModelPicker({
label: modelDisplayString(initial),
description: 'Current model',
},
]
];
}
return modelOptions
}, [modelOptions, initial])
return modelOptions;
}, [modelOptions, initial]);
const selectOptions = useMemo(
() =>
@@ -139,59 +131,46 @@ export function ModelPicker({
value: opt.value === null ? NO_PREFERENCE : opt.value,
})),
[optionsWithInitial],
)
);
const initialFocusValue = useMemo(
() =>
selectOptions.some(_ => _.value === initialValue)
? initialValue
: (selectOptions[0]?.value ?? undefined),
() => (selectOptions.some(_ => _.value === initialValue) ? initialValue : (selectOptions[0]?.value ?? undefined)),
[selectOptions, initialValue],
)
const visibleCount = Math.min(maxVisible, selectOptions.length)
const hiddenCount = Math.max(0, selectOptions.length - visibleCount)
);
const visibleCount = Math.min(maxVisible, selectOptions.length);
const hiddenCount = Math.max(0, selectOptions.length - visibleCount);
const focusedModelName = selectOptions.find(
opt => opt.value === focusedValue,
)?.label
const focusedModel = resolveOptionModel(focusedValue)
const is1MMarked = focusedValue !== undefined && focusedValue !== NO_PREFERENCE && marked1MValues.has(focusedValue)
const focusedSupportsEffort = focusedModel
? modelSupportsEffort(focusedModel)
: false
const focusedSupportsMax = focusedModel
? modelSupportsMaxEffort(focusedModel)
: false
const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue)
const focusedModelName = selectOptions.find(opt => opt.value === focusedValue)?.label;
const focusedModel = resolveOptionModel(focusedValue);
const is1MMarked =
focusedValue !== undefined &&
focusedValue !== NO_PREFERENCE &&
marked1MValues.has(focusedValue.replace(/\[1m\]/i, ''));
const focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false;
const focusedSupportsMax = focusedModel ? modelSupportsMaxEffort(focusedModel) : false;
const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue);
// Clamp display when 'max' is selected but the focused model doesn't support it.
// resolveAppliedEffort() does the same downgrade at API-send time.
const displayEffort =
effort === 'max' && !focusedSupportsMax ? 'high' : effort
const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort;
const handleFocus = useCallback(
(value: string) => {
setFocusedValue(value)
setFocusedValue(value);
if (!hasToggledEffort && effortValue === undefined) {
setEffort(getDefaultEffortLevelForOption(value))
setEffort(getDefaultEffortLevelForOption(value));
}
},
[hasToggledEffort, effortValue],
)
);
// Effort level cycling keybindings
const handleCycleEffort = useCallback(
(direction: 'left' | 'right') => {
if (!focusedSupportsEffort) return
setEffort(prev =>
cycleEffortLevel(
prev ?? focusedDefaultEffort,
direction,
focusedSupportsMax,
),
)
setHasToggledEffort(true)
if (!focusedSupportsEffort) return;
setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax));
setHasToggledEffort(true);
},
[focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort],
)
);
useKeybindings(
{
@@ -200,13 +179,12 @@ export function ModelPicker({
'modelPicker:toggle1M': () => handleToggle1M(),
},
{ context: 'ModelPicker' },
)
);
function handleSelect(value: string): void {
logEvent('tengu_model_command_menu_effort', {
effort:
effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
if (!skipSettingsWrite) {
// Prior comes from userSettings on disk — NOT merged settings (which
// includes project/policy layers that must not leak into the user's
@@ -218,28 +196,28 @@ export function ModelPicker({
getDefaultEffortLevelForOption(value),
getSettingsForSource('userSettings')?.effortLevel,
hasToggledEffort,
)
const persistable = toPersistableEffort(effortLevel)
);
const persistable = toPersistableEffort(effortLevel);
if (persistable !== undefined) {
updateSettingsForSource('userSettings', { effortLevel: persistable })
updateSettingsForSource('userSettings', { effortLevel: persistable });
}
setAppState(prev => ({ ...prev, effortValue: effortLevel }))
setAppState(prev => ({ ...prev, effortValue: effortLevel }));
}
const selectedModel = resolveOptionModel(value)
const selectedEffort =
hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel)
? effort
: undefined
const selectedModel = resolveOptionModel(value);
const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined;
if (value === NO_PREFERENCE) {
onSelect(null, selectedEffort)
return
onSelect(null, selectedEffort);
return;
}
// Apply or strip [1m] suffix based on user toggle
const wants1M = marked1MValues.has(value)
const baseValue = value.replace(/\[1m\]/i, '')
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
onSelect(finalValue, selectedEffort)
// Apply or strip [1m] suffix based on user toggle. marked1MValues is keyed
// on the base value (see initializer + handleToggle1M), so look up with the
// base form — not `value`, which may carry a `[1m]` suffix from predefined
// 1M options and would never match.
const baseValue = value.replace(/\[1m\]/i, '');
const wants1M = marked1MValues.has(baseValue);
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue;
onSelect(finalValue, selectedEffort);
}
const content = (
@@ -255,8 +233,8 @@ export function ModelPicker({
</Text>
{sessionModel && (
<Text dimColor>
Currently using {modelDisplayString(sessionModel)} for this
session (set by plan mode). Selecting a model will undo this.
Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model
will undo this.
</Text>
)}
</Box>
@@ -283,10 +261,8 @@ export function ModelPicker({
<Box marginBottom={1} flexDirection="column">
{focusedSupportsEffort ? (
<Text dimColor>
<EffortLevelIndicator effort={displayEffort} />{' '}
{capitalize(displayEffort)} effort
{displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '}
<Text color="subtle"> to adjust</Text>
<EffortLevelIndicator effort={displayEffort} /> {capitalize(displayEffort)} effort
{displayEffort === focusedDefaultEffort ? ` (default)` : ``} <Text color="subtle"> to adjust</Text>
</Text>
) : (
<Text color="subtle">
@@ -311,16 +287,14 @@ export function ModelPicker({
showFastModeNotice ? (
<Box marginBottom={1}>
<Text dimColor>
Fast mode is <Text bold>ON</Text> and available with{' '}
{FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other
models turn off fast mode.
Fast mode is <Text bold>ON</Text> and available with {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching
to other models turn off fast mode.
</Text>
</Box>
) : isFastModeAvailable() && !isFastModeCooldown() ? (
<Box marginBottom={1}>
<Text dimColor>
Use <Text bold>/fast</Text> to turn on Fast mode (
{FAST_MODE_MODEL_DISPLAY} only).
Use <Text bold>/fast</Text> to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only).
</Text>
</Box>
) : null
@@ -334,68 +308,45 @@ export function ModelPicker({
) : (
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint
action="select:cancel"
context="Select"
fallback="Esc"
description="exit"
/>
<ConfigurableShortcutHint action="select:cancel" context="Select" fallback="Esc" description="exit" />
</Byline>
)}
</Text>
)}
</Box>
)
);
if (!isStandaloneCommand) {
return content
return content;
}
return <Pane color="permission">{content}</Pane>
return <Pane color="permission">{content}</Pane>;
}
function resolveOptionModel(value?: string): string | undefined {
if (!value) return undefined
return value === NO_PREFERENCE
? getDefaultMainLoopModel()
: parseUserSpecifiedModel(value)
if (!value) return undefined;
return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value);
}
function EffortLevelIndicator({
effort,
}: {
effort?: EffortLevel
}): React.ReactNode {
return (
<Text color={effort ? 'claude' : 'subtle'}>
{effortLevelToSymbol(effort ?? 'low')}
</Text>
)
function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.ReactNode {
return <Text color={effort ? 'claude' : 'subtle'}>{effortLevelToSymbol(effort ?? 'low')}</Text>;
}
function cycleEffortLevel(
current: EffortLevel,
direction: 'left' | 'right',
includeMax: boolean,
): EffortLevel {
const levels: EffortLevel[] = includeMax
? ['low', 'medium', 'high', 'max']
: ['low', 'medium', 'high']
function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel {
const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high'];
// If the current level isn't in the cycle (e.g. 'max' after switching to a
// non-Opus model), clamp to 'high'.
const idx = levels.indexOf(current)
const currentIndex = idx !== -1 ? idx : levels.indexOf('high')
const idx = levels.indexOf(current);
const currentIndex = idx !== -1 ? idx : levels.indexOf('high');
if (direction === 'right') {
return levels[(currentIndex + 1) % levels.length]!
return levels[(currentIndex + 1) % levels.length]!;
} else {
return levels[(currentIndex - 1 + levels.length) % levels.length]!
return levels[(currentIndex - 1 + levels.length) % levels.length]!;
}
}
function getDefaultEffortLevelForOption(value?: string): EffortLevel {
const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel()
const defaultValue = getDefaultEffortForModel(resolved)
return defaultValue !== undefined
? convertEffortValueToLevel(defaultValue)
: 'high'
const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel();
const defaultValue = getDefaultEffortForModel(resolved);
return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high';
}