mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
test(frontend): Settings logic and new API key UI (#5873)
This commit is contained in:
parent
c37e865c56
commit
4de0a27ed2
@ -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;
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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]",
|
||||
}}
|
||||
|
||||
@ -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>) => {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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={[
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 };
|
||||
42
frontend/src/context/settings-up-to-date-context.tsx
Normal file
42
frontend/src/context/settings-up-to-date-context.tsx
Normal 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;
|
||||
}
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
@ -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(
|
||||
|
||||
41
frontend/src/hooks/mutation/use-save-settings.ts
Normal file
41
frontend/src/hooks/mutation/use-save-settings.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
39
frontend/src/hooks/query/use-settings.ts
Normal file
39
frontend/src/hooks/query/use-settings.ts
Normal 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;
|
||||
};
|
||||
54
frontend/src/hooks/use-maybe-migrate-settings.ts
Normal file
54
frontend/src/hooks/use-maybe-migrate-settings.ts
Normal 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();
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
@ -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" }),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
73
frontend/tests/settings.spec.ts
Normal file
73
frontend/tests/settings.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user