File size: 7,766 Bytes
3201ca6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | import React from "react";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
import { LlmSettingsScreen } from "#/routes/llm-settings";
import type { SdkSectionSaveControl } from "#/components/features/settings/sdk-settings/sdk-section-page";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useSaveLlmProfile } from "#/hooks/mutation/use-save-llm-profile";
import { useActivateLlmProfile } from "#/hooks/mutation/use-activate-llm-profile";
import { useApplyOnboardingAgentProfile } from "#/hooks/mutation/use-apply-onboarding-agent-profile";
import {
useDefaultModel,
useDefaultModelReady,
} from "#/hooks/query/use-free-models";
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
import { deriveProfileNameFromModel } from "#/utils/derive-profile-name";
interface SetupLlmStepProps {
onBack: () => void;
onNext: () => void;
}
/**
* Fallback when the backend has not exposed a DB-selected OpenHands default.
* The onboarding override still marks the model dirty so Next persists the
* suggested model immediately.
*/
export const ONBOARDING_DEFAULT_LLM_MODEL = "openai/gpt-5.6-sol";
/**
* Step 2: embed the LLM settings form. The screen runs in `embedded`
* mode (so it doesn't render its own sticky Save bar) and with
* `hideSaveButton` set, surfacing its save state via
* `onSaveControlChange`. We then render a single Next button at the
* modal footer level matching the other onboarding steps; clicking
* Next saves the form and `onSaveSuccess` advances to the next step.
*
* If the form happens to be untouched (no dirty fields), Next falls
* through to advancing without a save call, so users with already-
* configured settings aren't blocked.
*
* Note: returning Cloud users who already have an LLM configured are
* intercepted upstream by `OnboardingHost`, so they never reach this
* step. Users who do reach it are first-time installs (Cloud or Local)
* who want the OpenHands default pre-filled.
*/
export function SetupLlmStep({ onBack, onNext }: SetupLlmStepProps) {
const { t } = useTranslation("openhands");
const { backend } = useActiveBackend();
const isLocalBackend = backend.kind === "local";
const saveProfile = useSaveLlmProfile();
const activateProfile = useActivateLlmProfile();
const applyAgentProfile = useApplyOnboardingAgentProfile();
const dbDefaultLlmModel = useDefaultModel();
const isDefaultModelReady = useDefaultModelReady();
const defaultLlmModel = dbDefaultLlmModel ?? ONBOARDING_DEFAULT_LLM_MODEL;
const [saveControl, setSaveControl] =
React.useState<SdkSectionSaveControl | null>(null);
const [isFinalizing, setIsFinalizing] = React.useState(false);
// On local backends the LLM profiles list is the user-facing source of
// truth; without this step the form save only updates agent_settings and
// the new config never shows up in the profiles list ("ghost profile").
// Returns the saved LLM profile name so the caller can point the active
// AGENT profile at it (conversations launch from the agent profile, not the
// active LLM profile).
const persistAsProfile = React.useCallback(async (): Promise<
string | null
> => {
if (!isLocalBackend || !saveControl) return null;
const values = saveControl.values;
const model =
typeof values["llm.model"] === "string" ? values["llm.model"] : "";
if (!model) return null;
const apiKey =
typeof values["llm.api_key"] === "string" ? values["llm.api_key"] : "";
const baseUrl =
typeof values["llm.base_url"] === "string" ? values["llm.base_url"] : "";
const name = deriveProfileNameFromModel(model);
const llmConfig: { model: string; api_key?: string; base_url?: string } = {
model,
};
if (apiKey) llmConfig.api_key = apiKey;
if (baseUrl) llmConfig.base_url = baseUrl;
try {
await saveProfile.mutateAsync({
name,
request: { llm: llmConfig, include_secrets: true },
});
await activateProfile.mutateAsync(name);
return name;
} catch (error) {
// Best-effort: the agent_settings save already succeeded, so the
// user is not blocked from completing onboarding.
console.error("Failed to persist onboarding LLM as profile:", error);
return null;
}
}, [isLocalBackend, saveControl, saveProfile, activateProfile]);
const handleSaveSuccess = React.useCallback(async () => {
setIsFinalizing(true);
try {
const llmProfileName = await persistAsProfile();
// Point the active AGENT profile at the LLM the user just configured so
// the next conversation actually uses it (and the "LLM not set up"
// banner clears). Without this the active agent profile keeps its
// seeded llm_profile_ref, which has no key.
//
// Cloud intentionally skips this: `persistAsProfile` only creates/activates
// a *local* LLM profile (it early-returns null off local backends), and on
// cloud the agent-profile ↔ LLM wiring is resolved server-side from the
// settings this step's form save already persisted — there is no
// client-writable cloud agent-profile ref to repoint here. The ACP step
// still calls applyAgentProfile because ACP agents carry no LLM ref and
// persist their kind/model locally regardless of backend.
if (llmProfileName) {
await applyAgentProfile({
agent_kind: "openhands",
llm_profile_ref: llmProfileName,
});
}
} finally {
setIsFinalizing(false);
onNext();
}
}, [persistAsProfile, applyAgentProfile, onNext]);
const handleNext = () => {
if (saveControl?.isDirty) {
saveControl.save();
// `onSaveSuccess` (wired to `handleSaveSuccess` below) will advance
// once the mutation resolves successfully.
return;
}
onNext();
};
return (
<div
data-testid="onboarding-step-setup-llm"
className="flex flex-col gap-6 max-h-[calc(90vh-7rem)]"
>
<header className="flex flex-col gap-2">
<h2 className="text-2xl font-medium text-white">
{t(I18nKey.ONBOARDING$LLM_TITLE)}
</h2>
<p className="text-sm text-[var(--oh-muted)]">
{t(I18nKey.ONBOARDING$LLM_SUBTITLE)}
</p>
</header>
<div
data-testid="onboarding-llm-settings"
className="flex min-h-0 flex-1 flex-col overflow-y-auto custom-scrollbar-always"
>
{isDefaultModelReady ? (
<LlmSettingsScreen
embedded
hideSaveButton
suppressSuccessToast
initialValueOverrides={{
"llm.model": defaultLlmModel,
}}
onSaveSuccess={handleSaveSuccess}
onSaveControlChange={setSaveControl}
/>
) : (
<LlmSettingsInputsSkeleton />
)}
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-2 bg-base-secondary pt-4 pb-7">
<BrandButton
testId="onboarding-llm-back"
type="button"
variant="secondary"
onClick={onBack}
>
{t(I18nKey.ONBOARDING$BACK)}
</BrandButton>
<BrandButton
testId="onboarding-llm-next"
type="button"
variant="primary"
isDisabled={
!isDefaultModelReady ||
(saveControl?.isSaving ?? false) ||
isFinalizing
}
onClick={handleNext}
>
{t(I18nKey.ONBOARDING$NEXT)}
</BrandButton>
</div>
</div>
);
}
|