mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(models): support atomic preset renames
This commit is contained in:
@@ -3308,7 +3308,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
"settings.model_configuration.update",
|
"settings.model_configuration.update",
|
||||||
{
|
{
|
||||||
"name": "fast-writing",
|
"name": "fast-writing",
|
||||||
"label": "Codex",
|
"new_name": "Codex",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "openai/gpt-5.5",
|
"model": "openai/gpt-5.5",
|
||||||
},
|
},
|
||||||
@@ -3320,24 +3320,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
updated_presets = {
|
updated_presets = {
|
||||||
preset["name"]: preset for preset in updated_preset_body["model_presets"]
|
preset["name"]: preset for preset in updated_preset_body["model_presets"]
|
||||||
}
|
}
|
||||||
assert updated_presets["fast-writing"]["label"] == "fast-writing"
|
assert updated_presets["Codex"]["label"] == "Codex"
|
||||||
|
|
||||||
call_order_updated = await _webui_mutate(
|
call_order_updated = await _webui_mutate(
|
||||||
webui_client,
|
webui_client,
|
||||||
"settings.model_call_order.update",
|
"settings.model_call_order.update",
|
||||||
{"order": ["fast-writing", "deep"]},
|
{"order": ["Codex", "deep"]},
|
||||||
)
|
)
|
||||||
assert call_order_updated.status_code == 200
|
assert call_order_updated.status_code == 200
|
||||||
call_order_body = call_order_updated.json()
|
call_order_body = call_order_updated.json()
|
||||||
assert call_order_body["agent"]["model_preset"] == "fast-writing"
|
assert call_order_body["agent"]["model_preset"] == "Codex"
|
||||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
assert call_order_body["model_call_order"] == ["Codex", "deep"]
|
||||||
|
|
||||||
duplicate_preset = await _webui_mutate(
|
duplicate_preset = await _webui_mutate(
|
||||||
webui_client,
|
webui_client,
|
||||||
"settings.model_configuration.create",
|
"settings.model_configuration.create",
|
||||||
{
|
{
|
||||||
"label": "Fast writing",
|
"name": "codex",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "openai/gpt-4.1-mini",
|
"model": "openai/gpt-4.1-mini",
|
||||||
},
|
},
|
||||||
@@ -3435,10 +3435,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "atomic_chat"
|
assert saved.agents.defaults.provider == "atomic_chat"
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
assert saved.agents.defaults.model_preset == "Codex"
|
||||||
assert saved.agents.defaults.fallback_models == ["deep"]
|
assert saved.agents.defaults.fallback_models == ["deep"]
|
||||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
assert saved.model_presets["Codex"].model == "openai/gpt-5.5"
|
||||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
assert saved.model_presets["Codex"].provider == "openai"
|
||||||
assert saved.agents.defaults.timezone == "Asia/Shanghai"
|
assert saved.agents.defaults.timezone == "Asia/Shanghai"
|
||||||
assert saved.agents.defaults.bot_name == "nanobot"
|
assert saved.agents.defaults.bot_name == "nanobot"
|
||||||
assert saved.agents.defaults.bot_icon == "🐈"
|
assert saved.agents.defaults.bot_icon == "🐈"
|
||||||
|
|||||||
@@ -792,9 +792,40 @@ def _model_configuration_name(value: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _model_configuration_name_exists(config: Config, name: str) -> bool:
|
def _model_configuration_name_exists(
|
||||||
|
config: Config,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
exclude: str | None = None,
|
||||||
|
) -> bool:
|
||||||
normalized = name.casefold()
|
normalized = name.casefold()
|
||||||
return any(existing.casefold() == normalized for existing in config.model_presets)
|
return any(
|
||||||
|
existing != exclude and existing.casefold() == normalized
|
||||||
|
for existing in config.model_presets
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_model_configuration(config: Config, old_name: str, new_name: str) -> bool:
|
||||||
|
"""Rename one preset and every config reference to it."""
|
||||||
|
if old_name == new_name:
|
||||||
|
return False
|
||||||
|
if _model_configuration_name_exists(config, new_name, exclude=old_name):
|
||||||
|
raise WebUISettingsError("configuration already exists", status=409)
|
||||||
|
|
||||||
|
config.model_presets = {
|
||||||
|
(new_name if name == old_name else name): preset
|
||||||
|
for name, preset in config.model_presets.items()
|
||||||
|
}
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
if defaults.model_preset == old_name:
|
||||||
|
defaults.model_preset = new_name
|
||||||
|
defaults.fallback_models = [
|
||||||
|
new_name if fallback == old_name else fallback
|
||||||
|
for fallback in defaults.fallback_models
|
||||||
|
]
|
||||||
|
if defaults.dream.model_override == old_name:
|
||||||
|
defaults.dream.model_override = new_name
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _custom_provider_key(config: Config, display_name: str) -> str:
|
def _custom_provider_key(config: Config, display_name: str) -> str:
|
||||||
@@ -1139,6 +1170,13 @@ def update_model_configuration(
|
|||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
|
|
||||||
changed = False
|
changed = False
|
||||||
|
new_name_value = query_first_alias(query, "new_name", "newName")
|
||||||
|
if new_name_value is not None:
|
||||||
|
new_name = _model_configuration_name(new_name_value)
|
||||||
|
changed = _rename_model_configuration(config, name, new_name) or changed
|
||||||
|
name = new_name
|
||||||
|
preset = config.model_presets[name]
|
||||||
|
|
||||||
model = query_first(query, "model")
|
model = query_first(query, "model")
|
||||||
if model is not None:
|
if model is not None:
|
||||||
model = model.strip()
|
model = model.strip()
|
||||||
|
|||||||
@@ -418,6 +418,61 @@ def test_update_model_configuration_edits_named_preset_without_selecting(
|
|||||||
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
|
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_model_configuration_renames_preset_and_config_references(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {
|
||||||
|
"openai": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
|
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4"),
|
||||||
|
}
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
defaults.model_preset = "openai"
|
||||||
|
defaults.fallback_models = ["backup", "openai"]
|
||||||
|
defaults.dream.model_override = "openai"
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
payload = update_model_configuration(
|
||||||
|
{"name": ["openai"], "new_name": ["Codex"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["agent"]["model_preset"] == "Codex"
|
||||||
|
assert payload["model_call_order"] == ["Codex", "backup", "Codex"]
|
||||||
|
assert [row["name"] for row in payload["model_presets"]] == [
|
||||||
|
"default",
|
||||||
|
"Codex",
|
||||||
|
"backup",
|
||||||
|
]
|
||||||
|
saved = load_config(config_path)
|
||||||
|
assert list(saved.model_presets) == ["Codex", "backup"]
|
||||||
|
assert saved.agents.defaults.model_preset == "Codex"
|
||||||
|
assert saved.agents.defaults.fallback_models == ["backup", "Codex"]
|
||||||
|
assert saved.agents.defaults.dream.model_override == "Codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_model_configuration_rejects_duplicate_rename(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {
|
||||||
|
"openai": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
|
"Codex": ModelPresetConfig(model="openai/gpt-5.5"),
|
||||||
|
}
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
with pytest.raises(WebUISettingsError) as duplicate:
|
||||||
|
update_model_configuration({"name": ["openai"], "new_name": ["codex"]})
|
||||||
|
|
||||||
|
assert duplicate.value.status == 409
|
||||||
|
assert set(load_config(config_path).model_presets) == {"openai", "Codex"}
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_exposes_named_model_call_order(
|
def test_settings_payload_exposes_named_model_call_order(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function SettingsPage({
|
|||||||
modelMigrationSaving,
|
modelMigrationSaving,
|
||||||
modelPresetBeforeCreateRef,
|
modelPresetBeforeCreateRef,
|
||||||
modelPresetCreating,
|
modelPresetCreating,
|
||||||
|
modelPresetEditingName,
|
||||||
modelPresetPendingDelete,
|
modelPresetPendingDelete,
|
||||||
nanobotFeatureAction,
|
nanobotFeatureAction,
|
||||||
nanobotFeatureConfirm,
|
nanobotFeatureConfirm,
|
||||||
@@ -185,6 +186,7 @@ export function SettingsPage({
|
|||||||
setMcpOAuthCallbackError,
|
setMcpOAuthCallbackError,
|
||||||
setMcpOAuthCallbackUrl,
|
setMcpOAuthCallbackUrl,
|
||||||
setModelPresetCreating,
|
setModelPresetCreating,
|
||||||
|
setModelPresetEditingName,
|
||||||
setModelPresetPendingDelete,
|
setModelPresetPendingDelete,
|
||||||
setNanobotFeatureConfirm,
|
setNanobotFeatureConfirm,
|
||||||
setNanobotFeatures,
|
setNanobotFeatures,
|
||||||
@@ -240,6 +242,7 @@ export function SettingsPage({
|
|||||||
token={token}
|
token={token}
|
||||||
form={form}
|
form={form}
|
||||||
setForm={setForm}
|
setForm={setForm}
|
||||||
|
editingPresetName={modelPresetEditingName}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
dirty={modelDirty}
|
dirty={modelDirty}
|
||||||
creating={modelPresetCreating}
|
creating={modelPresetCreating}
|
||||||
@@ -256,8 +259,9 @@ export function SettingsPage({
|
|||||||
onMigrate={handleMigrateModelConfigurations}
|
onMigrate={handleMigrateModelConfigurations}
|
||||||
onBeginCreate={beginModelPresetCreation}
|
onBeginCreate={beginModelPresetCreation}
|
||||||
onCancelCreate={cancelModelPresetCreation}
|
onCancelCreate={cancelModelPresetCreation}
|
||||||
onSelectConfiguration={() => {
|
onSelectConfiguration={(name) => {
|
||||||
setModelPresetCreating(false);
|
setModelPresetCreating(false);
|
||||||
|
setModelPresetEditingName(name);
|
||||||
modelPresetBeforeCreateRef.current = null;
|
modelPresetBeforeCreateRef.current = null;
|
||||||
}}
|
}}
|
||||||
onDeleteConfiguration={setModelPresetPendingDelete}
|
onDeleteConfiguration={setModelPresetPendingDelete}
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export function ModelsSettings({
|
|||||||
token,
|
token,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
|
editingPresetName,
|
||||||
settings,
|
settings,
|
||||||
dirty,
|
dirty,
|
||||||
creating,
|
creating,
|
||||||
@@ -181,6 +182,7 @@ export function ModelsSettings({
|
|||||||
token: string;
|
token: string;
|
||||||
form: AgentSettingsDraft;
|
form: AgentSettingsDraft;
|
||||||
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
|
||||||
|
editingPresetName: string;
|
||||||
settings: SettingsPayload;
|
settings: SettingsPayload;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
@@ -197,7 +199,7 @@ export function ModelsSettings({
|
|||||||
onMigrate: () => void;
|
onMigrate: () => void;
|
||||||
onBeginCreate: () => void;
|
onBeginCreate: () => void;
|
||||||
onCancelCreate: () => void;
|
onCancelCreate: () => void;
|
||||||
onSelectConfiguration: () => void;
|
onSelectConfiguration: (name: string) => void;
|
||||||
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
|
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -230,7 +232,7 @@ export function ModelsSettings({
|
|||||||
preset,
|
preset,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
|
const selectedPreset = namedPresetsByName.get(editingPresetName) ?? null;
|
||||||
const activeEditorRowKey =
|
const activeEditorRowKey =
|
||||||
editorRowKey ??
|
editorRowKey ??
|
||||||
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
|
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
|
||||||
@@ -263,7 +265,7 @@ export function ModelsSettings({
|
|||||||
const modelFieldsMissing =
|
const modelFieldsMissing =
|
||||||
!form.model.trim() ||
|
!form.model.trim() ||
|
||||||
!form.provider.trim() ||
|
!form.provider.trim() ||
|
||||||
(creating && !form.modelPreset.trim()) ||
|
!form.modelPreset.trim() ||
|
||||||
form.maxTokens <= 0 ||
|
form.maxTokens <= 0 ||
|
||||||
form.temperature < 0 ||
|
form.temperature < 0 ||
|
||||||
form.temperature > 2;
|
form.temperature > 2;
|
||||||
@@ -277,7 +279,7 @@ export function ModelsSettings({
|
|||||||
) => {
|
) => {
|
||||||
const toggleCurrentPreset =
|
const toggleCurrentPreset =
|
||||||
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
|
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
|
||||||
onSelectConfiguration();
|
onSelectConfiguration(preset.name);
|
||||||
if (toggleCurrentPreset) {
|
if (toggleCurrentPreset) {
|
||||||
setEditorOpen((open) => !open);
|
setEditorOpen((open) => !open);
|
||||||
return;
|
return;
|
||||||
@@ -348,28 +350,21 @@ export function ModelsSettings({
|
|||||||
) : null}
|
) : null}
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={tx("settings.models.presetName", "Preset name")}
|
title={tx("settings.models.presetName", "Preset name")}
|
||||||
description={
|
description={tx(
|
||||||
creating
|
"settings.models.presetNameHelp",
|
||||||
? tx(
|
"Used everywhere, including /model commands. Names must be unique.",
|
||||||
"settings.models.presetNameHelp",
|
|
||||||
"Used everywhere, including /model commands. Names must be unique.",
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{creating ? (
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
value={form.modelPreset}
|
|
||||||
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
|
|
||||||
onChange={(event) =>
|
|
||||||
setForm((prev) => ({ ...prev, modelPreset: event.target.value }))
|
|
||||||
}
|
|
||||||
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-[13px] font-medium text-foreground">{form.modelPreset}</span>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus={creating}
|
||||||
|
aria-label={tx("settings.models.presetName", "Preset name")}
|
||||||
|
value={form.modelPreset}
|
||||||
|
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((prev) => ({ ...prev, modelPreset: event.target.value }))
|
||||||
|
}
|
||||||
|
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
|
||||||
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow title={t("settings.rows.provider")}>
|
<SettingsRow title={t("settings.rows.provider")}>
|
||||||
<ProviderPicker
|
<ProviderPicker
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export function useModelSettingsActions({
|
|||||||
modelMigrationSaving,
|
modelMigrationSaving,
|
||||||
modelPresetBeforeCreateRef,
|
modelPresetBeforeCreateRef,
|
||||||
modelPresetCreating,
|
modelPresetCreating,
|
||||||
|
modelPresetEditingName,
|
||||||
modelPresetPendingDelete,
|
modelPresetPendingDelete,
|
||||||
providerForms,
|
providerForms,
|
||||||
providerOAuthCompleting,
|
providerOAuthCompleting,
|
||||||
@@ -105,6 +106,7 @@ export function useModelSettingsActions({
|
|||||||
setModelConfigurationSaving,
|
setModelConfigurationSaving,
|
||||||
setModelMigrationSaving,
|
setModelMigrationSaving,
|
||||||
setModelPresetCreating,
|
setModelPresetCreating,
|
||||||
|
setModelPresetEditingName,
|
||||||
setModelPresetPendingDelete,
|
setModelPresetPendingDelete,
|
||||||
setProviderForms,
|
setProviderForms,
|
||||||
setProviderOAuthCompleting,
|
setProviderOAuthCompleting,
|
||||||
@@ -158,6 +160,7 @@ export function useModelSettingsActions({
|
|||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (createdPreset) {
|
if (createdPreset) {
|
||||||
setForm(agentDraftFromPayload(payload, createdPreset));
|
setForm(agentDraftFromPayload(payload, createdPreset));
|
||||||
|
setModelPresetEditingName(createdPreset);
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalPayload = payload;
|
let finalPayload = payload;
|
||||||
@@ -168,6 +171,7 @@ export function useModelSettingsActions({
|
|||||||
}
|
}
|
||||||
if (createdPreset) {
|
if (createdPreset) {
|
||||||
setForm(agentDraftFromPayload(finalPayload, createdPreset));
|
setForm(agentDraftFromPayload(finalPayload, createdPreset));
|
||||||
|
setModelPresetEditingName(createdPreset);
|
||||||
}
|
}
|
||||||
modelPresetBeforeCreateRef.current = null;
|
modelPresetBeforeCreateRef.current = null;
|
||||||
onModelNameChange(finalPayload.agent.model || null);
|
onModelNameChange(finalPayload.agent.model || null);
|
||||||
@@ -182,14 +186,16 @@ export function useModelSettingsActions({
|
|||||||
|
|
||||||
if (!modelDirty) return;
|
if (!modelDirty) return;
|
||||||
const selectedPreset = settings.model_presets.find(
|
const selectedPreset = settings.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
(preset) => !preset.is_default && preset.name === modelPresetEditingName,
|
||||||
);
|
);
|
||||||
if (!selectedPreset) return;
|
if (!selectedPreset) return;
|
||||||
|
const nextName = form.modelPreset.trim();
|
||||||
const reasoningEffort = form.reasoningEffort || null;
|
const reasoningEffort = form.reasoningEffort || null;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateModelConfiguration(client, {
|
const payload = await updateModelConfiguration(client, {
|
||||||
name: selectedPreset.name,
|
name: selectedPreset.name,
|
||||||
|
newName: nextName !== selectedPreset.name ? nextName : undefined,
|
||||||
model: form.model !== selectedPreset.model ? form.model : undefined,
|
model: form.model !== selectedPreset.model ? form.model : undefined,
|
||||||
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
|
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
|
||||||
maxTokens:
|
maxTokens:
|
||||||
@@ -205,7 +211,8 @@ export function useModelSettingsActions({
|
|||||||
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
||||||
});
|
});
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
setForm(agentDraftFromPayload(payload, nextName));
|
||||||
|
setModelPresetEditingName(nextName);
|
||||||
onModelNameChange(payload.agent.model || null);
|
onModelNameChange(payload.agent.model || null);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -227,7 +234,7 @@ export function useModelSettingsActions({
|
|||||||
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
|
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
|
||||||
configuredModelProviderOptions[0]?.name ??
|
configuredModelProviderOptions[0]?.name ??
|
||||||
"";
|
"";
|
||||||
modelPresetBeforeCreateRef.current = form.modelPreset;
|
modelPresetBeforeCreateRef.current = modelPresetEditingName;
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
modelPreset: "",
|
modelPreset: "",
|
||||||
@@ -248,6 +255,7 @@ export function useModelSettingsActions({
|
|||||||
const previousPreset = modelPresetBeforeCreateRef.current;
|
const previousPreset = modelPresetBeforeCreateRef.current;
|
||||||
setModelPresetCreating(false);
|
setModelPresetCreating(false);
|
||||||
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
|
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
|
||||||
|
setModelPresetEditingName(previousPreset ?? agentDraftFromPayload(settings).modelPreset);
|
||||||
modelPresetBeforeCreateRef.current = null;
|
modelPresetBeforeCreateRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { ProviderForm } from "@/components/settings/models/ProviderSettings
|
|||||||
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
|
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
|
||||||
|
|
||||||
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
|
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
|
||||||
|
const initialForm = initialSettings
|
||||||
|
? agentDraftFromPayload(initialSettings)
|
||||||
|
: DEFAULT_AGENT_SETTINGS_DRAFT;
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [modelPresetCreating, setModelPresetCreating] = useState(false);
|
const [modelPresetCreating, setModelPresetCreating] = useState(false);
|
||||||
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
|
||||||
@@ -28,8 +31,9 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
|
|||||||
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
|
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
|
||||||
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
|
||||||
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
|
||||||
const [form, setForm] = useState<AgentSettingsDraft>(() =>
|
const [form, setForm] = useState<AgentSettingsDraft>(initialForm);
|
||||||
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
|
const [modelPresetEditingName, setModelPresetEditingName] = useState(
|
||||||
|
initialForm.modelPreset,
|
||||||
);
|
);
|
||||||
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
|
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
|
||||||
() => initialSettings?.model_call_order ?? [],
|
() => initialSettings?.model_call_order ?? [],
|
||||||
@@ -45,6 +49,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
|
|||||||
modelMigrationSaving,
|
modelMigrationSaving,
|
||||||
modelPresetBeforeCreateRef,
|
modelPresetBeforeCreateRef,
|
||||||
modelPresetCreating,
|
modelPresetCreating,
|
||||||
|
modelPresetEditingName,
|
||||||
modelPresetPendingDelete,
|
modelPresetPendingDelete,
|
||||||
providerForms,
|
providerForms,
|
||||||
providerOAuthCompleting,
|
providerOAuthCompleting,
|
||||||
@@ -62,6 +67,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
|
|||||||
setModelConfigurationSaving,
|
setModelConfigurationSaving,
|
||||||
setModelMigrationSaving,
|
setModelMigrationSaving,
|
||||||
setModelPresetCreating,
|
setModelPresetCreating,
|
||||||
|
setModelPresetEditingName,
|
||||||
setModelPresetPendingDelete,
|
setModelPresetPendingDelete,
|
||||||
setProviderForms,
|
setProviderForms,
|
||||||
setProviderOAuthCompleting,
|
setProviderOAuthCompleting,
|
||||||
|
|||||||
@@ -93,10 +93,12 @@ export function useSettingsController({
|
|||||||
const {
|
const {
|
||||||
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
|
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
|
||||||
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
|
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
|
||||||
modelPresetCreating, modelPresetPendingDelete, providerForms, providerOAuthCompleting,
|
modelPresetCreating, modelPresetEditingName, modelPresetPendingDelete,
|
||||||
|
providerForms, providerOAuthCompleting,
|
||||||
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
|
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
|
||||||
providerSaving, saving, setForm,
|
providerSaving, saving, setForm,
|
||||||
setModelCallOrder, setModelPresetCreating, setModelPresetPendingDelete,
|
setModelCallOrder, setModelPresetCreating, setModelPresetEditingName,
|
||||||
|
setModelPresetPendingDelete,
|
||||||
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
|
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
|
||||||
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
|
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
|
||||||
} = modelState;
|
} = modelState;
|
||||||
@@ -147,7 +149,9 @@ export function useSettingsController({
|
|||||||
) => {
|
) => {
|
||||||
setSettings(payload);
|
setSettings(payload);
|
||||||
if (!options.preserveAgentForm) {
|
if (!options.preserveAgentForm) {
|
||||||
setForm(agentDraftFromPayload(payload));
|
const nextForm = agentDraftFromPayload(payload);
|
||||||
|
setForm(nextForm);
|
||||||
|
setModelPresetEditingName(nextForm.modelPreset);
|
||||||
setModelPresetCreating(false);
|
setModelPresetCreating(false);
|
||||||
}
|
}
|
||||||
setModelCallOrder(payload.model_call_order ?? []);
|
setModelCallOrder(payload.model_call_order ?? []);
|
||||||
@@ -252,10 +256,11 @@ export function useSettingsController({
|
|||||||
const modelDirty = useMemo(() => {
|
const modelDirty = useMemo(() => {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
const selectedPreset = settings.model_presets.find(
|
const selectedPreset = settings.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
(preset) => !preset.is_default && preset.name === modelPresetEditingName,
|
||||||
);
|
);
|
||||||
if (!selectedPreset) return false;
|
if (!selectedPreset) return false;
|
||||||
return (
|
return (
|
||||||
|
form.modelPreset.trim() !== selectedPreset.name ||
|
||||||
form.model !== selectedPreset.model ||
|
form.model !== selectedPreset.model ||
|
||||||
form.provider !== selectedPreset.provider ||
|
form.provider !== selectedPreset.provider ||
|
||||||
form.maxTokens !== selectedPreset.max_tokens ||
|
form.maxTokens !== selectedPreset.max_tokens ||
|
||||||
@@ -263,7 +268,7 @@ export function useSettingsController({
|
|||||||
form.temperature !== selectedPreset.temperature ||
|
form.temperature !== selectedPreset.temperature ||
|
||||||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "")
|
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "")
|
||||||
);
|
);
|
||||||
}, [form, settings]);
|
}, [form, modelPresetEditingName, settings]);
|
||||||
|
|
||||||
const imageGenerationDirty = useMemo(() => {
|
const imageGenerationDirty = useMemo(() => {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
@@ -532,6 +537,7 @@ export function useSettingsController({
|
|||||||
modelMigrationSaving,
|
modelMigrationSaving,
|
||||||
modelPresetBeforeCreateRef,
|
modelPresetBeforeCreateRef,
|
||||||
modelPresetCreating,
|
modelPresetCreating,
|
||||||
|
modelPresetEditingName,
|
||||||
modelPresetPendingDelete,
|
modelPresetPendingDelete,
|
||||||
nanobotFeatureAction,
|
nanobotFeatureAction,
|
||||||
nanobotFeatureConfirm,
|
nanobotFeatureConfirm,
|
||||||
@@ -581,6 +587,7 @@ export function useSettingsController({
|
|||||||
setMcpOAuthCallbackError,
|
setMcpOAuthCallbackError,
|
||||||
setMcpOAuthCallbackUrl,
|
setMcpOAuthCallbackUrl,
|
||||||
setModelPresetCreating,
|
setModelPresetCreating,
|
||||||
|
setModelPresetEditingName,
|
||||||
setModelPresetPendingDelete,
|
setModelPresetPendingDelete,
|
||||||
setNanobotFeatureConfirm,
|
setNanobotFeatureConfirm,
|
||||||
setNanobotFeatures,
|
setNanobotFeatures,
|
||||||
|
|||||||
@@ -927,6 +927,7 @@ export async function updateModelConfiguration(
|
|||||||
"settings.model_configuration.update",
|
"settings.model_configuration.update",
|
||||||
{
|
{
|
||||||
name: configuration.name,
|
name: configuration.name,
|
||||||
|
...(configuration.newName !== undefined ? { new_name: configuration.newName } : {}),
|
||||||
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
|
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
|
||||||
...(configuration.model !== undefined ? { model: configuration.model } : {}),
|
...(configuration.model !== undefined ? { model: configuration.model } : {}),
|
||||||
...modelGenerationSettingsPayload(configuration),
|
...modelGenerationSettingsPayload(configuration),
|
||||||
|
|||||||
@@ -1097,6 +1097,7 @@ export interface ModelConfigurationCreate {
|
|||||||
|
|
||||||
export interface ModelConfigurationUpdate {
|
export interface ModelConfigurationUpdate {
|
||||||
name: string;
|
name: string;
|
||||||
|
newName?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ describe("webui API helpers", () => {
|
|||||||
it("serializes model configuration updates", async () => {
|
it("serializes model configuration updates", async () => {
|
||||||
await updateModelConfiguration(mutationTransport, {
|
await updateModelConfiguration(mutationTransport, {
|
||||||
name: "codex",
|
name: "codex",
|
||||||
|
newName: "Codex",
|
||||||
provider: "openai_codex",
|
provider: "openai_codex",
|
||||||
model: "openai-codex/gpt-5.5",
|
model: "openai-codex/gpt-5.5",
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
@@ -478,6 +479,7 @@ describe("webui API helpers", () => {
|
|||||||
"settings.model_configuration.update",
|
"settings.model_configuration.update",
|
||||||
{
|
{
|
||||||
name: "codex",
|
name: "codex",
|
||||||
|
new_name: "Codex",
|
||||||
provider: "openai_codex",
|
provider: "openai_codex",
|
||||||
model: "openai-codex/gpt-5.5",
|
model: "openai-codex/gpt-5.5",
|
||||||
max_tokens: 8192,
|
max_tokens: 8192,
|
||||||
|
|||||||
@@ -106,10 +106,45 @@ describe("Settings models", () => {
|
|||||||
|
|
||||||
const editor = screen.getByTestId("model-preset-editor");
|
const editor = screen.getByTestId("model-preset-editor");
|
||||||
expect(within(editor).getByText("Preset name")).toBeInTheDocument();
|
expect(within(editor).getByText("Preset name")).toBeInTheDocument();
|
||||||
expect(within(editor).getByText("openai")).toBeInTheDocument();
|
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
|
||||||
|
"openai",
|
||||||
|
);
|
||||||
expect(within(editor).queryByText("minimax")).not.toBeInTheDocument();
|
expect(within(editor).queryByText("minimax")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renames an existing preset without losing the editor selection", async () => {
|
||||||
|
const payload = settingsPayload();
|
||||||
|
const renamedPayload: SettingsPayload = {
|
||||||
|
...payload,
|
||||||
|
agent: { ...payload.agent, model_preset: "Codex" },
|
||||||
|
model_presets: payload.model_presets.map((preset) => ({
|
||||||
|
...preset,
|
||||||
|
name: "Codex",
|
||||||
|
label: "Codex",
|
||||||
|
})),
|
||||||
|
model_call_order: ["Codex"],
|
||||||
|
};
|
||||||
|
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||||
|
requestMutationMock.mockResolvedValueOnce(renamedPayload);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||||
|
await togglePresetEditor();
|
||||||
|
|
||||||
|
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
|
||||||
|
fireEvent.change(nameInput, { target: { value: "Codex" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
|
"settings.model_configuration.update",
|
||||||
|
{ name: "primary", new_name: "Codex" },
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByTestId("model-call-order-row-Codex")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("textbox", { name: "Preset name" })).toHaveValue("Codex");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps generation parameters collapsed until advanced options are opened", async () => {
|
it("keeps generation parameters collapsed until advanced options are opened", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -187,7 +222,9 @@ describe("Settings models", () => {
|
|||||||
"lg:max-w-6xl",
|
"lg:max-w-6xl",
|
||||||
"rounded-floating",
|
"rounded-floating",
|
||||||
);
|
);
|
||||||
expect(within(editor).getByText("primary")).toBeInTheDocument();
|
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
|
||||||
|
"primary",
|
||||||
|
);
|
||||||
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
|
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
|
||||||
expect(deleteButton).toBeDisabled();
|
expect(deleteButton).toBeDisabled();
|
||||||
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");
|
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");
|
||||||
|
|||||||
Reference in New Issue
Block a user