Compare commits

...

2 Commits

Author SHA1 Message Date
amanape
c47503af9a merge and resolve 2025-04-11 16:41:01 +04:00
amanape
d3044d4e00 Break down settngs components 2025-04-09 17:12:02 +04:00
17 changed files with 628 additions and 348 deletions

View File

@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsSwitch } from "../settings-switch";
interface AdvancedSettingsSwitchProps {
defaultIsToggled: boolean;
onToggle: (isToggled: boolean) => void;
}
export function AdvancedSettingsSwitch({
defaultIsToggled,
onToggle,
}: AdvancedSettingsSwitchProps) {
const { t } = useTranslation();
return (
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={defaultIsToggled}
onToggle={onToggle}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
);
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsDropdownInput } from "../settings-dropdown-input";
interface AgentInputProps {
agents: string[];
defaultAgent: string;
}
export function AgentInput({ agents, defaultAgent }: AgentInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
defaultSelectedKey={defaultAgent}
isClearable={false}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import { SettingsInput } from "../settings-input";
import { I18nKey } from "#/i18n/declaration";
interface BaseUrlInputProps {
defaultBaseUrl: string;
}
export function BaseUrlInput({ defaultBaseUrl }: BaseUrlInputProps) {
const { t } = useTranslation();
return (
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={defaultBaseUrl}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
/>
);
}

View File

@@ -0,0 +1,46 @@
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ConfirmResetSettingsModalProps {
handleReset: () => void;
onClose: () => void;
}
export function ConfirmResetSettingsModal({
handleReset,
onClose,
}: ConfirmResetSettingsModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<div
data-testid="reset-modal"
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
>
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
<div className="w-full flex gap-2">
<BrandButton
type="button"
variant="primary"
className="grow"
onClick={handleReset}
>
Reset
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={onClose}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsSwitch } from "../settings-switch";
interface EnableAnalyticsSwitchProps {
defaultIsToggled: boolean;
}
export function EnableAnalyticsSwitch({
defaultIsToggled,
}: EnableAnalyticsSwitchProps) {
const { t } = useTranslation();
return (
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={defaultIsToggled}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
);
}

View File

@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsSwitch } from "../settings-switch";
interface EnableConfirmationModeSwitchProps {
onToggle: (isEnabled: boolean) => void;
defaultIsToggled: boolean;
}
export function EnableConfirmationModeSwitch({
defaultIsToggled,
onToggle,
}: EnableConfirmationModeSwitchProps) {
const { t } = useTranslation();
return (
<SettingsSwitch
testId="enable-confirmation-mode-switch"
onToggle={onToggle}
defaultIsToggled={defaultIsToggled}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
);
}

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsSwitch } from "../settings-switch";
interface EnableMemoryCondensorSwitchProps {
defaultIsToggled: boolean;
}
export function EnableMemoryCondensorSwitch({
defaultIsToggled,
}: EnableMemoryCondensorSwitchProps) {
const { t } = useTranslation();
return (
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={defaultIsToggled}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
);
}

View File

@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsSwitch } from "../settings-switch";
export function EnableSoundNotificationsSwitch({
defaultIsToggled,
}: {
defaultIsToggled: boolean;
}) {
const { t } = useTranslation();
return (
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={defaultIsToggled}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
);
}

View File

@@ -0,0 +1,107 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { KeyStatusIcon } from "../key-status-icon";
import { SettingsInput } from "../settings-input";
interface GitHubTokenInputProps {
isGitHubTokenSet: boolean;
}
export function GitHubTokenInput({ isGitHubTokenSet }: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<>
<SettingsInput
testId="github-token-input"
name="github-token-input"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitHubTokenSet && <KeyStatusIcon isSet={!!isGitHubTokenSet} />
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="github-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitHub
</a>{" "}
</b>
{t(I18nKey.COMMON$HERE)}{" "}
<b>
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
</a>
</b>
.
</p>
</>
);
}
interface GitLabTokenInputProps {
isGitLabTokenSet: boolean;
}
export function GitLabTokenInput({ isGitLabTokenSet }: GitLabTokenInputProps) {
const { t } = useTranslation();
return (
<>
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && <KeyStatusIcon isSet={!!isGitLabTokenSet} />
}
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
/>
<p data-testid="gitlab-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
{t(I18nKey.GITLAB$OR_SEE)}{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
</a>
</b>
.
</p>
</>
);
}

View File

@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { SettingsDropdownInput } from "../settings-dropdown-input";
interface LanguageInputProps {
defaultLanguage: string;
}
export function LanguageInput({ defaultLanguage }: LanguageInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId="language-input"
name="language-input"
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((language) => ({
key: language.value,
label: language.label,
}))}
defaultSelectedKey={defaultLanguage}
isClearable={false}
/>
);
}

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { HelpLink } from "../help-link";
import { KeyStatusIcon } from "../key-status-icon";
import { SettingsInput } from "../settings-input";
interface LlmApiKeyInputProps {
isLLMKeySet: boolean;
}
export function LlmApiKeyInput({ isLLMKeySet }: LlmApiKeyInputProps) {
const { t } = useTranslation();
return (
<>
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
interface LlmCustomModelInputProps {
defaultModel: string;
}
export function LlmCustomModelInput({
defaultModel,
}: LlmCustomModelInputProps) {
const { t } = useTranslation();
return (
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={defaultModel}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
/>
);
}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
// Define REMOTE_RUNTIME_OPTIONS for testing
const REMOTE_RUNTIME_OPTIONS = [
{ key: "1", label: "Standard" },
{ key: "2", label: "Enhanced" },
{ key: "4", label: "Premium" },
];
interface RuntimeSettingsInputProps {
defaultRuntime?: string;
}
export function RuntimeSettingsInput({
defaultRuntime,
}: RuntimeSettingsInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
</>
}
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={defaultRuntime}
isDisabled
isClearable={false}
/>
);
}

View File

@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
interface SecurityAnalzerInputProps {
securityAnalyzers: string[];
defaultSecurityAnalyzer: string;
}
export function SecurityAnalzerInput({
defaultSecurityAnalyzer,
securityAnalyzers,
}: SecurityAnalzerInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
items={securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
}))}
defaultSelectedKey={defaultSecurityAnalyzer}
isClearable
showOptionalTag
/>
);
}

View File

@@ -1,30 +0,0 @@
import { Input } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface BaseUrlInputProps {
isDisabled: boolean;
defaultValue: string;
}
export function BaseUrlInput({ isDisabled, defaultValue }: BaseUrlInputProps) {
const { t } = useTranslation();
return (
<fieldset className="flex flex-col gap-2">
<label htmlFor="base-url" className="font-[500] text-[#A3A3A3] text-xs">
{t(I18nKey.SETTINGS_FORM$BASE_URL_LABEL)}
</label>
<Input
isDisabled={isDisabled}
id="base-url"
name="base-url"
defaultValue={defaultValue}
aria-label={t(I18nKey.SETTINGS_FORM$BASE_URL)}
classNames={{
inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
}}
/>
</fieldset>
);
}

View File

@@ -3,21 +3,13 @@ import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { HelpLink } from "#/components/features/settings/help-link";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { useAppLogout } from "#/hooks/use-app-logout";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { isCustomModel } from "#/utils/is-custom-model";
@@ -29,13 +21,24 @@ import {
} from "#/utils/custom-toast-handlers";
import { ProviderOptions } from "#/types/settings";
import { useAuth } from "#/context/auth-context";
// Define REMOTE_RUNTIME_OPTIONS for testing
const REMOTE_RUNTIME_OPTIONS = [
{ key: "1", label: "Standard" },
{ key: "2", label: "Enhanced" },
{ key: "4", label: "Premium" },
];
import { LlmCustomModelInput } from "#/components/features/settings/account/llm-custom-model-input";
import { BaseUrlInput } from "#/components/features/settings/account/base-url-input";
import { AgentInput } from "#/components/features/settings/account/agent-input";
import { RuntimeSettingsInput } from "#/components/features/settings/account/runtime-settings-input";
import { EnableConfirmationModeSwitch } from "#/components/features/settings/account/enable-confirmation-mode-switch";
import { EnableMemoryCondensorSwitch } from "#/components/features/settings/account/enable-memory-condensor-switch";
import { SecurityAnalzerInput } from "#/components/features/settings/account/security-analyzer-input";
import { EnableAnalyticsSwitch } from "#/components/features/settings/account/enable-analytics-switch";
import { EnableSoundNotificationsSwitch } from "#/components/features/settings/account/enable-sound-notification-switch";
import { LanguageInput } from "#/components/features/settings/account/language-input";
import { buildUserPreferences } from "#/utils/build-user-preferences";
import {
GitHubTokenInput,
GitLabTokenInput,
} from "#/components/features/settings/account/git-provider-token-input";
import { ConfirmResetSettingsModal } from "#/components/features/settings/account/confirm-reset-settings-modal";
import { AdvancedSettingsSwitch } from "#/components/features/settings/account/advanced-settings-switch";
import { LlmApiKeyInput } from "#/components/features/settings/account/llm-api-key-input";
function AccountSettings() {
const { t } = useTranslation();
@@ -100,76 +103,20 @@ function AccountSettings() {
const formRef = React.useRef<HTMLFormElement>(null);
/**
* Submits the user's preferences to the server.
*/
const onSubmit = async (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const llmProvider = formData.get("llm-provider-input")?.toString();
const llmModel = formData.get("llm-model-input")?.toString();
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
const rawRemoteRuntimeResourceFactor = formData
.get("runtime-settings-input")
?.toString();
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
({ label }) => label === rawRemoteRuntimeResourceFactor,
)?.key;
const userConsentsToAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
const enableMemoryCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
const llmApiKey =
inputApiKey === "" && isLLMKeySet
? undefined // don't update if it's already set and input is empty
: inputApiKey; // otherwise use the input value
const githubToken = formData.get("github-token-input")?.toString();
const gitlabToken = formData.get("gitlab-token-input")?.toString();
// we don't want the user to be able to modify these settings in SaaS
const finalLlmModel = shouldHandleSpecialSaasCase
? undefined
: customLlmModel || fullLlmModel;
const finalLlmBaseUrl = shouldHandleSpecialSaasCase
? undefined
: llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const newSettings = {
provider_tokens:
githubToken || gitlabToken
? {
github: githubToken || "",
gitlab: gitlabToken || "",
}
: undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
llm_api_key: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor !== null
? Number(remoteRuntimeResourceFactor)
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
};
const newSettings = buildUserPreferences(
formData,
isLLMKeySet,
shouldHandleSpecialSaasCase,
confirmationModeIsEnabled,
);
saveSettings(newSettings, {
onSuccess: () => {
handleCaptureConsent(userConsentsToAnalytics);
handleCaptureConsent(newSettings.user_consents_to_analytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
@@ -180,6 +127,9 @@ function AccountSettings() {
});
};
/**
* Resets the user's settings to the default values.
*/
const handleReset = () => {
saveSettings(null, {
onSuccess: () => {
@@ -236,13 +186,10 @@ function AccountSettings() {
{t(I18nKey.SETTINGS$LLM_SETTINGS)}
</h2>
{!shouldHandleSpecialSaasCase && (
<SettingsSwitch
testId="advanced-settings-switch"
<AdvancedSettingsSwitch
defaultIsToggled={isAdvancedSettingsSet}
onToggle={onToggleAdvancedMode}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
/>
)}
</div>
@@ -254,125 +201,48 @@ function AccountSettings() {
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={settings.LLM_MODEL}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
/>
<LlmCustomModelInput defaultModel={settings.LLM_MODEL} />
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
/>
<BaseUrlInput defaultBaseUrl={settings.LLM_BASE_URL} />
)}
{!shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
/>
)}
{!shouldHandleSpecialSaasCase && (
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
<LlmApiKeyInput isLLMKeySet={!!isLLMKeySet} />
)}
{llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
defaultSelectedKey={settings.AGENT}
isClearable={false}
<AgentInput
agents={resources?.agents || []}
defaultAgent={settings.AGENT}
/>
)}
{isSaas && llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
)
</>
}
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isDisabled
isClearable={false}
<RuntimeSettingsInput
defaultRuntime={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-confirmation-mode-switch"
<EnableConfirmationModeSwitch
onToggle={setConfirmationModeIsEnabled}
defaultIsToggled={!!settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
/>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
<EnableMemoryCondensorSwitch
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
/>
)}
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
<div>
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
/>
</div>
<SecurityAnalzerInput
securityAnalyzers={resources?.securityAnalyzers || []}
defaultSecurityAnalyzer={settings.SECURITY_ANALYZER}
/>
)}
</section>
)}
@@ -381,6 +251,7 @@ function AccountSettings() {
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.SETTINGS$GITHUB_SETTINGS)}
</h2>
{isSaas && hasAppSlug && (
<Link
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
@@ -392,90 +263,12 @@ function AccountSettings() {
</BrandButton>
</Link>
)}
{!isSaas && (
<>
<SettingsInput
testId="github-token-input"
name="github-token-input"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitHubTokenSet && (
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="github-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitHub
</a>{" "}
</b>
{t(I18nKey.COMMON$HERE)}{" "}
<b>
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
</a>
</b>
.
</p>
<GitHubTokenInput isGitHubTokenSet={isGitHubTokenSet} />
<GitLabTokenInput isGitLabTokenSet={isGitLabTokenSet} />
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && (
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="gitlab-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITLAB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
{t(I18nKey.GITLAB$OR_SEE)}{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$DOCUMENTATION)}
</a>
</b>
.
</p>
<BrandButton
type="button"
variant="secondary"
@@ -493,33 +286,11 @@ function AccountSettings() {
{t(I18nKey.ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS)}
</h2>
<SettingsDropdownInput
testId="language-input"
name="language-input"
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((language) => ({
key: language.value,
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
isClearable={false}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!isAnalyticsEnabled}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
<LanguageInput defaultLanguage={settings.LANGUAGE} />
<EnableAnalyticsSwitch defaultIsToggled={!!isAnalyticsEnabled} />
<EnableSoundNotificationsSwitch
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
/>
</section>
</div>
</form>
@@ -544,37 +315,10 @@ function AccountSettings() {
</footer>
{resetSettingsModalIsOpen && (
<ModalBackdrop>
<div
data-testid="reset-modal"
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
>
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
<div className="w-full flex gap-2">
<BrandButton
type="button"
variant="primary"
className="grow"
onClick={() => {
handleReset();
}}
>
Reset
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={() => {
setResetSettingsModalIsOpen(false);
}}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
<ConfirmResetSettingsModal
handleReset={handleReset}
onClose={() => setResetSettingsModalIsOpen(false)}
/>
)}
</>
);

View File

@@ -0,0 +1,90 @@
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { PostSettings } from "#/types/settings";
const REMOTE_RUNTIME_OPTIONS = [
{ key: "1", label: "Standard" },
{ key: "2", label: "Enhanced" },
{ key: "4", label: "Premium" },
];
/**
* Builds user preferences object from form data and configuration flags
* @param formData - Form data containing user preferences
* @param isLLMKeySet - Flag indicating if LLM key is already set
* @param shouldHandleSpecialSaasCase - Flag for special SaaS case handling
* @param confirmationModeIsEnabled - Flag for confirmation mode status
* @returns User settings configuration object
*/
export const buildUserPreferences = (
formData: FormData,
isLLMKeySet: boolean | undefined,
shouldHandleSpecialSaasCase: boolean | undefined,
confirmationModeIsEnabled: boolean,
): Partial<PostSettings> & { user_consents_to_analytics: boolean } => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const llmProvider = formData.get("llm-provider-input")?.toString();
const llmModel = formData.get("llm-model-input")?.toString();
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
const rawRemoteRuntimeResourceFactor = formData
.get("runtime-settings-input")
?.toString();
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
({ label }) => label === rawRemoteRuntimeResourceFactor,
)?.key;
const userConsentsToAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
const enableMemoryCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
const llmApiKey =
inputApiKey === "" && isLLMKeySet
? undefined // don't update if it's already set and input is empty
: inputApiKey; // otherwise use the input value
const githubToken = formData.get("github-token-input")?.toString();
const gitlabToken = formData.get("gitlab-token-input")?.toString();
// we don't want the user to be able to modify these settings in SaaS
const finalLlmModel = shouldHandleSpecialSaasCase
? undefined
: customLlmModel || fullLlmModel;
const finalLlmBaseUrl = shouldHandleSpecialSaasCase ? undefined : llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const newSettings = {
provider_tokens:
githubToken || gitlabToken
? {
github: githubToken || "",
gitlab: gitlabToken || "",
}
: undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
llm_api_key: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor !== null
? Number(remoteRuntimeResourceFactor)
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
};
return newSettings;
};