Files
nanobot/webui/src/components/settings/models/useModelSettingsActions.ts
T

610 lines
20 KiB
TypeScript

import { useCallback, type Dispatch, type SetStateAction } from "react";
import type { TFunction } from "i18next";
import type {
ApplySettingsPayload,
MaybeRestartHostEngine,
PendingRestartSections,
} from "@/components/settings/contracts";
import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
import {
CUSTOM_PROVIDER_CREATION_KEY,
providerFormFromRow,
type CustomProviderDraft,
} from "@/components/settings/models/ProviderSettings";
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
import {
ApiError,
completeProviderOAuth,
createModelConfiguration,
createProviderSettings,
deleteModelConfiguration,
loginProviderOAuth,
logoutProviderOAuth,
migrateModelConfigurations,
updateModelCallOrder,
updateModelConfiguration,
updateProviderSettings,
} from "@/lib/api";
import type { NanobotClient } from "@/lib/nanobot-client";
import type {
ProviderOAuthAuthorizationRequired,
ProviderOAuthCompletionResult,
ProviderOAuthLoginResult,
ProviderOAuthPending,
ProviderSettingsUpdate,
SettingsPayload,
} from "@/lib/types";
function isProviderOAuthAuthorizationRequired(
payload: ProviderOAuthLoginResult,
): payload is ProviderOAuthAuthorizationRequired {
return (payload as ProviderOAuthAuthorizationRequired).status === "authorization_required";
}
function isProviderOAuthPending(
payload: ProviderOAuthCompletionResult,
): payload is ProviderOAuthPending {
return (payload as ProviderOAuthPending).status === "pending";
}
interface ModelSettingsActionsOptions {
state: ModelSettingsState;
settings: SettingsPayload | null;
client: NanobotClient;
t: TFunction;
applyPayload: ApplySettingsPayload;
maybeRestartHostEngine: MaybeRestartHostEngine;
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
setError: Dispatch<SetStateAction<string | null>>;
onModelNameChange: (modelName: string | null) => void;
remoteBrowserAccess: boolean;
closeProviderOAuthFlow: () => void;
installCapabilities: (names: string[]) => Promise<boolean>;
modelDirty: boolean;
configuredModelProviderOptions: Array<{ name: string; label: string }>;
}
export function useModelSettingsActions({
state,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
onModelNameChange,
remoteBrowserAccess,
closeProviderOAuthFlow,
installCapabilities,
modelDirty,
configuredModelProviderOptions,
}: ModelSettingsActionsOptions) {
const {
expandedProvider,
form,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
providerOAuthFlowRef,
providerOAuthResponse,
providerSaving,
saving,
setEditingProviderKeys,
setExpandedProvider,
setForm,
setModelCallOrder,
setModelCallOrderSaving,
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetNameError,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
setProviderOAuthDialogError,
setProviderOAuthFlow,
setProviderOAuthResponse,
setProviderSaving,
setSaving,
setVisibleProviderKeys,
visibleProviderKeys,
} = state;
const presetNameConflict = (name: string, currentName?: string) => {
const normalized = name.toLowerCase();
return settings?.model_presets.some(
(preset) =>
!preset.is_default &&
preset.name !== currentName &&
preset.name.toLowerCase() === normalized,
) ?? false;
};
const showPresetNameConflict = () => {
setModelPresetNameError(
t("settings.models.presetNameDuplicate", {
defaultValue: "A preset with this name already exists.",
}),
);
setError(null);
};
const handlePresetSaveError = (reason: unknown) => {
if (reason instanceof ApiError && reason.status === 409) {
showPresetNameConflict();
return;
}
setError((reason as Error).message);
};
const saveModelSettings = async () => {
if (
!settings ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving
) {
return;
}
if (modelPresetCreating) {
const name = form.modelPreset.trim();
const provider = form.provider.trim();
const model = form.model.trim();
if (
!name ||
!provider ||
!model ||
form.maxTokens <= 0 ||
form.contextWindowTokens <= 0 ||
form.temperature < 0 ||
form.temperature > 2
) {
return;
}
if (presetNameConflict(name)) {
showPresetNameConflict();
return;
}
setModelPresetNameError(null);
setModelConfigurationSaving(true);
try {
const payload = await createModelConfiguration(client, {
name,
provider,
model,
maxTokens: form.maxTokens,
contextWindowTokens: form.contextWindowTokens,
temperature: form.temperature,
reasoningEffort: form.reasoningEffort || null,
});
const createdPreset = payload.created_model_preset;
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
applyPayload(payload);
if (createdPreset) {
setForm(agentDraftFromPayload(payload, createdPreset));
setModelPresetEditingName(createdPreset);
}
let finalPayload = payload;
if (nextOrder) {
const orderedPayload = await updateModelCallOrder(client, nextOrder);
applyPayload(orderedPayload);
finalPayload = orderedPayload;
}
if (createdPreset) {
setForm(agentDraftFromPayload(finalPayload, createdPreset));
setModelPresetEditingName(createdPreset);
}
modelPresetBeforeCreateRef.current = null;
onModelNameChange(finalPayload.agent.model || null);
setModelPresetNameError(null);
setError(null);
} catch (err) {
handlePresetSaveError(err);
} finally {
setModelConfigurationSaving(false);
}
return;
}
if (!modelDirty) return;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === modelPresetEditingName,
);
if (!selectedPreset) return;
const nextName = form.modelPreset.trim();
const nameChanged = form.modelPreset !== selectedPreset.name;
if (nameChanged && presetNameConflict(nextName, selectedPreset.name)) {
showPresetNameConflict();
return;
}
setModelPresetNameError(null);
const reasoningEffort = form.reasoningEffort || null;
setSaving(true);
try {
const payload = await updateModelConfiguration(client, {
name: selectedPreset.name,
newName: nameChanged ? nextName : undefined,
model: form.model !== selectedPreset.model ? form.model : undefined,
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
maxTokens:
form.maxTokens !== selectedPreset.max_tokens ? form.maxTokens : undefined,
contextWindowTokens:
form.contextWindowTokens !==
normalizeContextWindowTokens(selectedPreset.context_window_tokens)
? form.contextWindowTokens
: undefined,
temperature:
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
reasoningEffort:
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
});
applyPayload(payload);
setForm(agentDraftFromPayload(payload, nextName));
setModelPresetEditingName(nextName);
onModelNameChange(payload.agent.model || null);
setModelPresetNameError(null);
setError(null);
} catch (err) {
handlePresetSaveError(err);
} finally {
setSaving(false);
}
};
const beginModelPresetCreation = () => {
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
const primaryPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
);
const currentProvider = primaryPreset?.provider === "auto"
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
: primaryPreset?.provider ?? settings.agent.provider;
const provider =
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
configuredModelProviderOptions[0]?.name ??
"";
modelPresetBeforeCreateRef.current = modelPresetEditingName;
setModelPresetNameError(null);
setForm((prev) => ({
...prev,
modelPreset: "",
provider,
model: "",
maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens,
contextWindowTokens: normalizeContextWindowTokens(
primaryPreset?.context_window_tokens ?? settings.agent.context_window_tokens,
),
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
}));
setModelPresetCreating(true);
};
const cancelModelPresetCreation = () => {
if (!settings || modelConfigurationSaving) return;
const previousPreset = modelPresetBeforeCreateRef.current;
setModelPresetCreating(false);
setModelPresetNameError(null);
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
setModelPresetEditingName(previousPreset ?? agentDraftFromPayload(settings).modelPreset);
modelPresetBeforeCreateRef.current = null;
};
const changeModelCallOrder = async (nextOrder: string[]) => {
const unchanged =
nextOrder.length === modelCallOrder.length &&
nextOrder.every((name, index) => name === modelCallOrder[index]);
if (
!settings ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving ||
nextOrder.length === 0 ||
unchanged
) {
return;
}
const previousOrder = [...modelCallOrder];
setModelCallOrder(nextOrder);
setModelCallOrderSaving(true);
try {
const payload = await updateModelCallOrder(client, nextOrder);
applyPayload(payload, { preserveAgentForm: true });
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setModelCallOrder(previousOrder);
setError((err as Error).message);
} finally {
setModelCallOrderSaving(false);
}
};
const handleMigrateModelConfigurations = async () => {
if (modelMigrationSaving) return;
setModelMigrationSaving(true);
try {
const payload = await migrateModelConfigurations(client);
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setModelMigrationSaving(false);
}
};
const handleDeleteModelConfiguration = async () => {
if (
!modelPresetPendingDelete ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving
) {
return;
}
setSaving(true);
try {
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
applyPayload(payload);
setModelPresetPendingDelete(null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const saveProvider = async (providerName: string) => {
if (providerSaving) return;
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
const isOauthProvider = provider.auth_type === "oauth";
const providerForm = providerForms[providerName] ?? providerFormFromRow(provider);
const apiKey = providerForm.apiKey.trim();
const apiKeyRequired = provider.api_key_required ?? true;
if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
setProviderSaving(providerName);
try {
const supportName = providerName === "bedrock"
? "bedrock"
: providerName === "azure_openai"
? "azure"
: null;
if (supportName && !(await installCapabilities([supportName]))) return;
const update: ProviderSettingsUpdate = { provider: providerName };
if (!isOauthProvider) {
update.apiKey = apiKey || undefined;
update.apiBase = providerForm.apiBase.trim();
if (provider.is_custom) update.displayName = providerForm.displayName.trim();
}
for (const field of provider.advanced_fields ?? []) {
if (field === "api_type") update.apiType = providerForm.apiType;
if (field === "proxy") update.proxy = providerForm.proxy.trim();
if (field === "extra_headers") {
update.extraHeaders = providerForm.extraHeaders.trim();
}
if (field === "extra_body") update.extraBody = providerForm.extraBody.trim();
if (field === "extra_query") update.extraQuery = providerForm.extraQuery.trim();
if (field === "thinking_style") {
update.thinkingStyle = providerForm.thinkingStyle.trim();
}
if (field === "region") update.region = providerForm.region.trim();
if (field === "profile") update.profile = providerForm.profile.trim();
}
const payload = await updateProviderSettings(client, update);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
}
await maybeRestartHostEngine(payload);
setProviderForms((prev) => ({
...prev,
[providerName]: {
...providerForm,
displayName: providerForm.displayName.trim(),
apiKey: "",
apiBase: providerForm.apiBase.trim(),
proxy: providerForm.proxy.trim(),
thinkingStyle: providerForm.thinkingStyle.trim(),
region: providerForm.region.trim(),
profile: providerForm.profile.trim(),
},
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
if (!isOauthProvider) setExpandedProvider(null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const createCustomProvider = async (draft: CustomProviderDraft): Promise<boolean> => {
if (providerSaving) return false;
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
try {
const payload = await createProviderSettings(client, {
name: draft.name.trim(),
apiKey: draft.apiKey.trim() || undefined,
apiBase: draft.apiBase.trim(),
proxy: draft.proxy.trim(),
extraHeaders: draft.extraHeaders.trim(),
extraBody: draft.extraBody.trim(),
extraQuery: draft.extraQuery.trim(),
thinkingStyle: draft.thinkingStyle.trim(),
});
applyPayload(payload);
setExpandedProvider(null);
setError(null);
return true;
} catch (err) {
setError((err as Error).message);
return false;
} finally {
setProviderSaving(null);
}
};
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
if (providerSaving) return;
let popup: Window | null = null;
if (
action === "login"
&& providerName === "xai_grok"
&& !remoteBrowserAccess
) {
try {
popup = window.open("about:blank", "_blank");
if (popup) popup.opener = null;
} catch {
popup = null;
}
}
setProviderSaving(providerName);
try {
const payload =
action === "login"
? await loginProviderOAuth(
client,
providerName,
providerName === "openai_codex" && remoteBrowserAccess,
)
: await logoutProviderOAuth(client, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) {
try {
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
} catch {
// The dialog keeps the authorization link available when the popup was closed.
}
providerOAuthFlowRef.current = payload;
setProviderOAuthFlow(payload);
setProviderOAuthResponse("");
setProviderOAuthDialogError(null);
setExpandedProvider(providerName);
setError(null);
return;
}
popup?.close();
closeProviderOAuthFlow();
applyPayload(payload);
setExpandedProvider(providerName);
setError(null);
} catch (err) {
popup?.close();
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const completeProviderOAuthResponse = async () => {
const flow = providerOAuthFlowRef.current;
const authorizationResponse = providerOAuthResponse.trim();
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
setProviderOAuthCompleting(true);
setProviderOAuthDialogError(null);
try {
const payload = await completeProviderOAuth(
client,
flow.provider,
flow.flow_id,
authorizationResponse,
);
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
if (isProviderOAuthPending(payload)) return;
applyPayload(payload);
setExpandedProvider(flow.provider);
setError(null);
closeProviderOAuthFlow();
} catch (err) {
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
setProviderOAuthDialogError((err as Error).message);
}
} finally {
setProviderOAuthCompleting(false);
}
};
const resetProviderDraft = useCallback((providerName: string) => {
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
setProviderForms((prev) => ({
...prev,
[providerName]: providerFormFromRow(provider),
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
}, [settings]);
const handleToggleProvider = useCallback((providerName: string) => {
if (expandedProvider) resetProviderDraft(expandedProvider);
setExpandedProvider(expandedProvider === providerName ? null : providerName);
}, [expandedProvider, resetProviderDraft]);
const toggleProviderKeyVisibility = (providerName: string) => {
const isVisible = visibleProviderKeys[providerName];
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
};
const toggleProviderKeyEditing = (providerName: string) => {
setEditingProviderKeys((prev) => {
const nextEditing = !prev[providerName];
if (!nextEditing) {
setProviderForms((forms) => ({
...forms,
[providerName]: {
...(forms[providerName] ?? providerFormFromRow(
settings?.providers.find((provider) => provider.name === providerName) ?? {
name: providerName,
label: providerName,
configured: false,
},
)),
apiKey: "",
},
}));
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
}
return { ...prev, [providerName]: nextEditing };
});
};
return {
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
completeProviderOAuthResponse,
createCustomProvider,
handleDeleteModelConfiguration,
handleMigrateModelConfigurations,
handleToggleProvider,
resetProviderDraft,
runProviderOAuth,
saveModelSettings,
saveProvider,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
};
}