test(frontend): Settings logic and new API key UI (#5873)

This commit is contained in:
sp.wack 2024-12-31 13:40:51 +04:00 committed by GitHub
parent c37e865c56
commit 4de0a27ed2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 433 additions and 247 deletions

View File

@ -11,7 +11,7 @@ import {
Conversation,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { Settings } from "#/services/settings";
import { ApiSettings, Settings } from "#/services/settings";
class OpenHands {
/**
@ -297,6 +297,23 @@ class OpenHands {
});
return data;
}
/**
* Get the settings from the server or use the default settings if not found
*/
static async getSettings(): Promise<ApiSettings> {
const { data } = await openHands.get<ApiSettings>("/api/settings");
return data;
}
/**
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
static async saveSettings(settings: Partial<ApiSettings>): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
}
export default OpenHands;

View File

@ -2,7 +2,6 @@ import React from "react";
import { useLocation } from "react-router";
import FolderIcon from "#/icons/docs.svg?react";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { UserActions } from "./user-actions";
@ -14,18 +13,19 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import { useSettings } from "#/hooks/query/use-settings";
import { ConversationPanel } from "../conversation-panel/conversation-panel";
import { cn } from "#/utils/utils";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
export function Sidebar() {
const location = useLocation();
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
const { logout } = useAuth();
const { settingsAreUpToDate } = useSettings();
const { data: settings, isError: settingsIsError } = useSettings();
const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
@ -109,9 +109,13 @@ export function Sidebar() {
{accountSettingsModalOpen && (
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
)}
{showSettingsModal && (
<SettingsModal onClose={() => setSettingsModalIsOpen(false)} />
)}
{settingsIsError ||
(showSettingsModal && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}
/>
))}
{startNewProjectModalIsOpen && (
<ExitProjectConfirmationModal
onClose={() => setStartNewProjectModalIsOpen(false)}

View File

@ -7,7 +7,12 @@ interface SettingsButtonProps {
export function SettingsButton({ onClick }: SettingsButtonProps) {
return (
<TooltipButton tooltip="Settings" ariaLabel="Settings" onClick={onClick}>
<TooltipButton
testId="settings-button"
tooltip="Settings"
ariaLabel="Settings"
onClick={onClick}
>
<CogTooth />
</TooltipButton>
);

View File

@ -1,27 +1,37 @@
import { Input } from "@nextui-org/react";
import { Input, Tooltip } from "@nextui-org/react";
import { useTranslation } from "react-i18next";
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
import { I18nKey } from "#/i18n/declaration";
interface APIKeyInputProps {
isDisabled: boolean;
defaultValue: string;
isSet: boolean;
}
export function APIKeyInput({ isDisabled, defaultValue }: APIKeyInputProps) {
export function APIKeyInput({ isDisabled, isSet }: APIKeyInputProps) {
const { t } = useTranslation();
return (
<fieldset data-testid="api-key-input" className="flex flex-col gap-2">
<label htmlFor="api-key" className="font-[500] text-[#A3A3A3] text-xs">
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
</label>
<Tooltip content={isSet ? "API Key is set" : "API Key is not set"}>
<label
htmlFor="api-key"
className="font-[500] text-[#A3A3A3] text-xs flex items-center gap-1 self-start"
>
{isSet && <FaCheckCircle className="text-[#00D1B2] inline-block" />}
{!isSet && (
<FaExclamationCircle className="text-[#FF3860] inline-block" />
)}
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
</label>
</Tooltip>
<Input
isDisabled={isDisabled}
id="api-key"
name="api-key"
aria-label="API Key"
type="password"
defaultValue={defaultValue}
defaultValue=""
classNames={{
inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
}}

View File

@ -8,12 +8,12 @@ import { ModalBody } from "../modal-body";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { ModalButton } from "../../buttons/modal-button";
import { CustomInput } from "../../custom-input";
import { FormFieldset } from "../../form-fieldset";
import { useConfig } from "#/hooks/query/use-config";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
interface AccountSettingsFormProps {
onClose: () => void;
@ -30,7 +30,7 @@ export function AccountSettingsForm({
}: AccountSettingsFormProps) {
const { gitHubToken, setGitHubToken, logout } = useAuth();
const { data: config } = useConfig();
const { saveSettings } = useSettings();
const { mutate: saveSettings } = useSaveSettings();
const { t } = useTranslation();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {

View File

@ -1,5 +1,5 @@
import { useSettings } from "#/context/settings-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useSettings } from "#/hooks/query/use-settings";
import { ModalBackdrop } from "../modal-backdrop";
import { AccountSettingsForm } from "./account-settings-form";
@ -9,7 +9,7 @@ interface AccountSettingsModalProps {
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
const user = useGitHubUser();
const { settings } = useSettings();
const { data: settings } = useSettings();
// FIXME: Bad practice to use localStorage directly
const analyticsConsent = localStorage.getItem("analytics-consent");

View File

@ -35,14 +35,20 @@ export function BaseModalDescription({
}
interface BaseModalProps {
testId?: string;
title: string;
description: string;
buttons: ButtonConfig[];
}
export function BaseModal({ title, description, buttons }: BaseModalProps) {
export function BaseModal({
testId,
title,
description,
buttons,
}: BaseModalProps) {
return (
<ModalBody>
<ModalBody testID={testId}>
<div className="flex flex-col gap-2 self-start">
<BaseModalTitle title={title} />
<BaseModalDescription description={description} />

View File

@ -1,6 +1,8 @@
import { BaseModal } from "./base-modal";
interface DangerModalProps {
testId?: string;
title: string;
description: string;
@ -10,9 +12,15 @@ interface DangerModalProps {
};
}
export function DangerModal({ title, description, buttons }: DangerModalProps) {
export function DangerModal({
testId,
title,
description,
buttons,
}: DangerModalProps) {
return (
<BaseModal
testId={testId}
title={title}
description={description}
buttons={[

View File

@ -68,6 +68,7 @@ export function ModelSelector({
LLM Provider
</label>
<Autocomplete
data-testid="llm-provider"
isRequired
isVirtualized={false}
name="llm-provider"
@ -91,7 +92,11 @@ export function ModelSelector({
{Object.keys(models)
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
.map((provider) => (
<AutocompleteItem key={provider} value={provider}>
<AutocompleteItem
data-testid={`provider-item-${provider}`}
key={provider}
value={provider}
>
{mapProvider(provider)}
</AutocompleteItem>
))}
@ -113,6 +118,7 @@ export function ModelSelector({
LLM Model
</label>
<Autocomplete
data-testid="llm-model"
isRequired
isVirtualized={false}
name="llm-model"
@ -144,7 +150,11 @@ export function ModelSelector({
{models[selectedProvider || ""]?.models
.filter((model) => !VERIFIED_MODELS.includes(model))
.map((model) => (
<AutocompleteItem key={model} value={model}>
<AutocompleteItem
data-testid={`model-item-${model}`}
key={model}
value={model}
>
{model}
</AutocompleteItem>
))}

View File

@ -9,7 +9,6 @@ import { DangerModal } from "../confirmation-modals/danger-modal";
import { I18nKey } from "#/i18n/declaration";
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { useSettings } from "#/context/settings-context";
import { ModalButton } from "../../buttons/modal-button";
import { AdvancedOptionSwitch } from "../../inputs/advanced-option-switch";
import { AgentInput } from "../../inputs/agent-input";
@ -20,6 +19,7 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
interface SettingsFormProps {
disabled?: boolean;
@ -38,7 +38,7 @@ export function SettingsForm({
securityAnalyzers,
onClose,
}: SettingsFormProps) {
const { saveSettings } = useSettings();
const { mutateAsync: saveSettings } = useSaveSettings();
const endSession = useEndSession();
const location = useLocation();
@ -82,7 +82,6 @@ export function SettingsForm({
const resetOngoingSession = () => {
if (location.pathname.startsWith("/conversations/")) {
endSession();
onClose();
}
};
@ -92,7 +91,7 @@ export function SettingsForm({
const newSettings = extractSettings(formData);
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
await saveSettings(newSettings);
await saveSettings(newSettings, { onSuccess: onClose });
resetOngoingSession();
posthog.capture("settings_saved", {
@ -102,11 +101,9 @@ export function SettingsForm({
};
const handleConfirmResetSettings = async () => {
await saveSettings(getDefaultSettings());
await saveSettings(getDefaultSettings(), { onSuccess: onClose });
resetOngoingSession();
posthog.capture("settings_reset");
onClose();
};
const handleConfirmEndSession = () => {
@ -122,7 +119,6 @@ export function SettingsForm({
setConfirmEndSessionModalOpen(true);
} else {
handleFormSubmission(formData);
onClose();
}
};
@ -165,7 +161,7 @@ export function SettingsForm({
<APIKeyInput
isDisabled={!!disabled}
defaultValue={settings.LLM_API_KEY || ""}
isSet={settings.LLM_API_KEY === "SET"}
/>
{showAdvancedOptions && (
@ -221,6 +217,7 @@ export function SettingsForm({
{confirmResetDefaultsModalOpen && (
<ModalBackdrop>
<DangerModal
testId="reset-defaults-modal"
title={t(I18nKey.SETTINGS_FORM$ARE_YOU_SURE_LABEL)}
description={t(
I18nKey.SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE,

View File

@ -1,15 +1,15 @@
import { useSettings } from "#/context/settings-context";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { Settings } from "#/services/settings";
import { LoadingSpinner } from "../../loading-spinner";
import { ModalBackdrop } from "../modal-backdrop";
import { SettingsForm } from "./settings-form";
interface SettingsModalProps {
settings: Settings;
onClose: () => void;
}
export function SettingsModal({ onClose }: SettingsModalProps) {
const { settings } = useSettings();
export function SettingsModal({ onClose, settings }: SettingsModalProps) {
const aiConfigOptions = useAIConfigOptions();
return (

View File

@ -1,68 +0,0 @@
import React from "react";
import posthog from "posthog-js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSettings,
Settings,
saveSettings,
settingsAreUpToDate as checkIfSettingsAreUpToDate,
DEFAULT_SETTINGS,
} from "#/services/settings";
interface SettingsContextType {
settings: Settings;
settingsAreUpToDate: boolean;
saveSettings: (settings: Partial<Settings>) => void;
}
const SettingsContext = React.createContext<SettingsContextType | undefined>(
undefined,
);
const SETTINGS_QUERY_KEY = ["settings"];
function SettingsProvider({ children }: React.PropsWithChildren) {
const { data: settings } = useQuery({
queryKey: SETTINGS_QUERY_KEY,
queryFn: getSettings,
initialData: DEFAULT_SETTINGS,
});
const [settingsAreUpToDate, setSettingsAreUpToDate] = React.useState(
checkIfSettingsAreUpToDate(),
);
const queryClient = useQueryClient();
const handleSaveSettings = async (newSettings: Partial<Settings>) => {
await saveSettings(newSettings);
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
};
React.useEffect(() => {
if (settings?.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [settings?.LLM_API_KEY]);
const value = React.useMemo(
() => ({
settings,
settingsAreUpToDate,
saveSettings: handleSaveSettings,
}),
[settings, settingsAreUpToDate],
);
return <SettingsContext value={value}>{children}</SettingsContext>;
}
function useSettings() {
const context = React.useContext(SettingsContext);
if (context === undefined) {
throw new Error("useSettings must be used within a SettingsProvider");
}
return context;
}
export { SettingsProvider, useSettings };

View File

@ -0,0 +1,42 @@
import React from "react";
import { settingsAreUpToDate } from "#/services/settings";
interface SettingsUpToDateContextType {
isUpToDate: boolean;
setIsUpToDate: (value: boolean) => void;
}
const SettingsUpToDateContext = React.createContext<
SettingsUpToDateContextType | undefined
>(undefined);
interface SettingsUpToDateProviderProps {
children: React.ReactNode;
}
export function SettingsUpToDateProvider({
children,
}: SettingsUpToDateProviderProps) {
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
const value = React.useMemo(
() => ({ isUpToDate, setIsUpToDate }),
[isUpToDate, setIsUpToDate],
);
return (
<SettingsUpToDateContext.Provider value={value}>
{children}
</SettingsUpToDateContext.Provider>
);
}
export function useSettingsUpToDate() {
const context = React.useContext(SettingsUpToDateContext);
if (context === undefined) {
throw new Error(
"useSettingsUpToDate must be used within a SettingsUpToDateProvider",
);
}
return context;
}

View File

@ -20,7 +20,7 @@ import toast from "react-hot-toast";
import store from "./store";
import { useConfig } from "./hooks/query/use-config";
import { AuthProvider } from "./context/auth-context";
import { SettingsProvider } from "./context/settings-context";
import { SettingsUpToDateProvider } from "./context/settings-up-to-date-context";
function PosthogInit() {
const { data: config } = useConfig();
@ -71,14 +71,14 @@ prepareApp().then(() =>
document,
<StrictMode>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<SettingsProvider>
<AuthProvider>
<AuthProvider>
<SettingsUpToDateProvider>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
</AuthProvider>
</SettingsProvider>
</QueryClientProvider>
</QueryClientProvider>
</SettingsUpToDateProvider>
</AuthProvider>
</Provider>
</StrictMode>,
);

View File

@ -6,13 +6,13 @@ import OpenHands from "#/api/open-hands";
import { setInitialQuery } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { useSettings } from "../query/use-settings";
export const useCreateConversation = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { gitHubToken } = useAuth();
const { settings } = useSettings();
const { data: settings } = useSettings();
const queryClient = useQueryClient();
const { selectedRepository, files } = useSelector(

View File

@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ApiSettings,
LATEST_SETTINGS_VERSION,
Settings,
} from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
const apiSettings: Partial<ApiSettings> = {
llm_model: settings.LLM_MODEL,
llm_base_url: settings.LLM_BASE_URL,
agent: settings.AGENT,
language: settings.LANGUAGE,
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
llm_api_key: settings.LLM_API_KEY,
};
await OpenHands.saveSettings(apiSettings);
};
export const useSaveSettings = () => {
const queryClient = useQueryClient();
const { isUpToDate, setIsUpToDate } = useSettingsUpToDate();
return useMutation({
mutationFn: saveSettingsMutationFn,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (!isUpToDate) {
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),
);
setIsUpToDate(true);
}
},
});
};

View File

@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings";
import OpenHands from "#/api/open-hands";
const getSettingsQueryFn = async () => {
const apiSettings = await OpenHands.getSettings();
if (apiSettings !== null) {
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: apiSettings.llm_api_key,
};
}
return getLocalStorageSettings();
};
export const useSettings = () => {
const query = useQuery({
queryKey: ["settings"],
queryFn: getSettingsQueryFn,
initialData: DEFAULT_SETTINGS,
});
React.useEffect(() => {
if (query.data?.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [query.data?.LLM_API_KEY]);
return query;
};

View File

@ -0,0 +1,54 @@
// Sometimes we ship major changes, like a new default agent.
import React from "react";
import { useAuth } from "#/context/auth-context";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import {
DEFAULT_SETTINGS,
getCurrentSettingsVersion,
getLocalStorageSettings,
} from "#/services/settings";
import { useSaveSettings } from "./mutation/use-save-settings";
// In this case, we may want to override a previous choice made by the user.
export const useMaybeMigrateSettings = () => {
const { logout } = useAuth();
const { mutateAsync: saveSettings } = useSaveSettings();
const { isUpToDate } = useSettingsUpToDate();
const maybeMigrateSettings = async () => {
const currentVersion = getCurrentSettingsVersion();
if (currentVersion < 1) {
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
}
if (currentVersion < 2) {
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
if (customModel) {
localStorage.setItem("LLM_MODEL", customModel);
}
localStorage.removeItem("CUSTOM_LLM_MODEL");
localStorage.removeItem("USING_CUSTOM_MODEL");
}
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
logout();
}
// Only save settings if user already previously saved settings
// That way we avoid setting defaults for new users too early
if (currentVersion !== 0 && currentVersion < 5) {
const localSettings = getLocalStorageSettings();
await saveSettings(localSettings);
}
};
React.useEffect(() => {
if (!isUpToDate) {
maybeMigrateSettings();
}
}, []);
};

View File

@ -1,5 +1,18 @@
import { delay, http, HttpResponse } from "msw";
import { Conversation } from "#/api/open-hands.types";
import { GetConfigResponse, Conversation } from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
const userPreferences = {
settings: {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
},
};
const conversations: Conversation[] = [
{
@ -35,24 +48,22 @@ const CONVERSATIONS = new Map<string, Conversation>(
);
const openHandsHandlers = [
http.get("/api/options/models", async () => {
await delay();
return HttpResponse.json([
http.get("/api/options/models", async () =>
HttpResponse.json([
"gpt-3.5-turbo",
"gpt-4o",
"anthropic/claude-3.5",
]);
}),
"anthropic/claude-3-5-sonnet-20241022",
]),
),
http.get("/api/options/agents", async () => {
await delay();
return HttpResponse.json(["CodeActAgent", "CoActAgent"]);
}),
http.get("/api/options/agents", async () =>
HttpResponse.json(["CodeActAgent", "CoActAgent"]),
),
http.get("/api/options/security-analyzers", async () => {
await delay();
return HttpResponse.json(["mock-invariant"]);
}),
http.get("/api/options/security-analyzers", async () =>
HttpResponse.json(["mock-invariant"]),
),
http.get(
"http://localhost:3001/api/conversations/:conversationId/list-files",
@ -137,6 +148,33 @@ export const handlers = [
http.post("https://us.i.posthog.com/e", async () =>
HttpResponse.json(null, { status: 200 }),
),
http.get("/api/options/config", () => {
const config: GetConfigResponse = {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
};
return HttpResponse.json(config);
}),
http.get("/api/settings", async () =>
HttpResponse.json(userPreferences.settings),
),
http.post("/api/settings", async ({ request }) => {
const body = await request.json();
if (body) {
userPreferences.settings = {
...userPreferences.settings,
// @ts-expect-error - We know this is a settings object
...body,
};
return HttpResponse.json(null, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
http.post("/api/authenticate", async () =>
HttpResponse.json({ message: "Authenticated" }),

View File

@ -22,7 +22,6 @@ import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "./event-handler";
import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { Container } from "#/components/layout/container";
import {
@ -34,14 +33,16 @@ import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
import { CountBadge } from "#/components/layout/count-badge";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
function AppContent() {
const { gitHubToken } = useAuth();
const { data: settings } = useSettings();
const endSession = useEndSession();
const [width, setWidth] = React.useState(window.innerWidth);
const { settings } = useSettings();
const { conversationId } = useConversation();
const dispatch = useDispatch();

View File

@ -4,12 +4,12 @@ import i18n from "#/i18n";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { updateSettingsVersion } from "#/utils/settings-utils";
import { useConfig } from "#/hooks/query/use-config";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import { useSettings } from "#/hooks/query/use-settings";
import { useMaybeMigrateSettings } from "#/hooks/use-maybe-migrate-settings";
export function ErrorBoundary() {
const error = useRouteError();
@ -44,9 +44,10 @@ export function ErrorBoundary() {
}
export default function MainApp() {
useMaybeMigrateSettings();
const { gitHubToken } = useAuth();
const { settings } = useSettings();
const { logout } = useAuth();
const { data: settings } = useSettings();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
@ -67,10 +68,6 @@ export default function MainApp() {
}
}, [settings.LANGUAGE]);
React.useEffect(() => {
updateSettingsVersion(logout);
}, []);
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";

View File

@ -1,5 +1,3 @@
import { openHands } from "#/api/open-hands-axios";
export const LATEST_SETTINGS_VERSION = 5;
export type Settings = {
@ -46,6 +44,11 @@ export const settingsAreUpToDate = () =>
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
/**
* Get the settings from local storage
* @returns the settings from local storage
* @deprecated
*/
export const getLocalStorageSettings = (): Settings => {
const llmModel = localStorage.getItem("LLM_MODEL");
const baseUrl = localStorage.getItem("LLM_BASE_URL");
@ -66,83 +69,7 @@ export const getLocalStorageSettings = (): Settings => {
};
};
/**
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
export const saveSettings = async (
settings: Partial<Settings>,
): Promise<boolean> => {
try {
const apiSettings = {
llm_model: settings.LLM_MODEL || null,
llm_base_url: settings.LLM_BASE_URL || null,
agent: settings.AGENT || null,
language: settings.LANGUAGE || null,
confirmation_mode: settings.CONFIRMATION_MODE || null,
security_analyzer: settings.SECURITY_ANALYZER || null,
llm_api_key: settings.LLM_API_KEY || null,
};
const { data } = await openHands.post("/api/settings", apiSettings);
return data;
} catch (error) {
// Error handled by returning false
return false;
}
};
export const maybeMigrateSettings = async (logout: () => void) => {
// Sometimes we ship major changes, like a new default agent.
// In this case, we may want to override a previous choice made by the user.
const currentVersion = getCurrentSettingsVersion();
if (currentVersion < 1) {
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
}
if (currentVersion < 2) {
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
if (customModel) {
localStorage.setItem("LLM_MODEL", customModel);
}
localStorage.removeItem("CUSTOM_LLM_MODEL");
localStorage.removeItem("USING_CUSTOM_MODEL");
}
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
logout();
}
if (currentVersion < 5) {
const localSettings = getLocalStorageSettings();
await saveSettings(localSettings);
}
};
/**
* Get the default settings
*/
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
/**
* Get the settings from the server or use the default settings if not found
*/
export const getSettings = async (): Promise<Settings> => {
const { data: apiSettings } =
await openHands.get<ApiSettings>("/api/settings");
if (apiSettings != null) {
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: "",
};
}
return getLocalStorageSettings();
};

View File

@ -1,9 +1,4 @@
import {
settingsAreUpToDate,
maybeMigrateSettings,
LATEST_SETTINGS_VERSION,
Settings,
} from "#/services/settings";
import { Settings } from "#/services/settings";
const extractBasicFormData = (formData: FormData) => {
const provider = formData.get("llm-provider")?.toString();
@ -78,18 +73,4 @@ const saveSettingsView = (view: "basic" | "advanced") => {
);
};
/**
* Updates the settings version in local storage if the current settings are not up to date.
* If the settings are outdated, it attempts to migrate them before updating the version.
*/
const updateSettingsVersion = async (logout: () => void) => {
if (!settingsAreUpToDate()) {
await maybeMigrateSettings(logout);
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),
);
}
};
export { extractSettings, saveSettingsView, updateSettingsVersion };
export { extractSettings, saveSettingsView };

View File

@ -10,8 +10,8 @@ import i18n from "i18next";
import { vi } from "vitest";
import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { SettingsProvider } from "#/context/settings-context";
import { ConversationProvider } from "#/context/conversation-context";
import { SettingsUpToDateProvider } from "#/context/settings-up-to-date-context";
// Mock useParams before importing components
vi.mock("react-router", async () => {
@ -66,15 +66,15 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<QueryClientProvider client={new QueryClient()}>
<SettingsProvider>
<AuthProvider>
<AuthProvider>
<SettingsUpToDateProvider>
<QueryClientProvider client={new QueryClient()}>
<ConversationProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</ConversationProvider>
</AuthProvider>
</SettingsProvider>
</QueryClientProvider>
</QueryClientProvider>
</SettingsUpToDateProvider>
</AuthProvider>
</Provider>
);
}

View File

@ -0,0 +1,73 @@
import test, { expect, Page } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("analytics-consent", "true");
localStorage.setItem("SETTINGS_VERSION", "4");
});
});
const selectGpt4o = async (page: Page) => {
const aiConfigModal = page.getByTestId("ai-config-modal");
await expect(aiConfigModal).toBeVisible();
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
await providerSelectElement.click();
const openAiOption = page.getByTestId("provider-item-openai");
await openAiOption.click();
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
await modelSelectElement.click();
const gpt4Option = page.getByText("gpt-4o", { exact: true });
await gpt4Option.click();
return {
aiConfigModal,
providerSelectElement,
modelSelectElement,
};
};
test("change ai config settings", async ({ page }) => {
const { aiConfigModal, modelSelectElement, providerSelectElement } =
await selectGpt4o(page);
const saveButton = aiConfigModal.getByText("Save");
await saveButton.click();
const settingsButton = page.getByTestId("settings-button");
await settingsButton.click();
await expect(providerSelectElement).toHaveValue("OpenAI");
await expect(modelSelectElement).toHaveValue("gpt-4o");
});
test("reset to default settings", async ({ page }) => {
const { aiConfigModal } = await selectGpt4o(page);
const saveButton = aiConfigModal.getByText("Save");
await saveButton.click();
const settingsButton = page.getByTestId("settings-button");
await settingsButton.click();
const resetButton = aiConfigModal.getByText(/reset to defaults/i);
await resetButton.click();
const endSessionModal = page.getByTestId("reset-defaults-modal");
expect(endSessionModal).toBeVisible();
const confirmButton = endSessionModal.getByText(/reset to defaults/i);
await confirmButton.click();
await settingsButton.click();
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
await expect(providerSelectElement).toHaveValue("Anthropic");
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
await expect(modelSelectElement).toHaveValue(/claude-3.5/i);
});

View File

@ -12,7 +12,8 @@ app = APIRouter(prefix='/api')
SettingsStoreImpl = get_impl(SettingsStore, openhands_config.settings_store_class) # type: ignore
ConversationStoreImpl = get_impl(
ConversationStore, openhands_config.conversation_store_class # type: ignore
ConversationStore, # type: ignore
openhands_config.conversation_store_class,
)
@ -28,7 +29,7 @@ async def load_settings(
settings = await settings_store.load()
if settings:
# For security reasons we don't ever send the api key to the client
settings.llm_api_key = None
settings.llm_api_key = 'SET' if settings.llm_api_key else None
return settings
except Exception as e:
logger.warning(f'Invalid token: {e}')
@ -50,7 +51,10 @@ async def store_settings(
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
existing_settings = await settings_store.load()
if existing_settings:
settings = Settings(**{**existing_settings.__dict__, **settings.__dict__})
# Only update settings that are not None with the new values
for key, value in settings.__dict__.items():
if value is None:
setattr(settings, key, getattr(existing_settings, key))
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
await settings_store.store(settings)