refactor: add General/Security pages, remove SDK_LEGACY_FIELD_MAP, fix inferInitialView

- Add /settings/general and /settings/security sidebar pages rendering
  their respective SDK schema sections
- Reorder nav: General above LLM, Security below LLM (both SAAS + OSS)
- Remove SDK_LEGACY_FIELD_MAP and all legacy field bridging — the only
  canonical store for SDK settings is now sdk_settings_values
- Simplify to_agent_settings(), _extract_sdk_settings_values(), and
  _apply_settings_payload() to read/write sdk_settings_values only
- Fix inferInitialView to accept an optional schemaOverride so
  SdkSectionPage passes filteredSchema (prevents cross-section
  minor-value overrides from elevating the view tier on unrelated pages)
- Add SETTINGS$NAV_GENERAL and SETTINGS$NAV_SECURITY i18n keys with
  translations for all 14 languages
- Use lock.svg for Security icon and settings.svg for General icon

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-03-17 01:46:11 +00:00
parent cfa7def554
commit 3a12924bc8
12 changed files with 112 additions and 72 deletions

View File

@@ -139,8 +139,8 @@ export function SdkSectionPage({
if (!settings?.sdk_settings_schema) return;
setValues(buildInitialSettingsFormValues(settings));
setDirty({});
setView(inferInitialView(settings));
}, [settings]);
setView(inferInitialView(settings, filteredSchema));
}, [settings, filteredSchema]);
const visibleSections = React.useMemo(() => {
if (!filteredSchema) return [];

View File

@@ -2,9 +2,11 @@ import { FiUsers, FiBriefcase } from "react-icons/fi";
import CreditCardIcon from "#/icons/credit-card.svg?react";
import KeyIcon from "#/icons/key.svg?react";
import LightbulbIcon from "#/icons/lightbulb.svg?react";
import LockIcon from "#/icons/lock.svg?react";
import MemoryIcon from "#/icons/memory_icon.svg?react";
import ServerProcessIcon from "#/icons/server-process.svg?react";
import SettingsGearIcon from "#/icons/settings-gear.svg?react";
import SettingsIcon from "#/icons/settings.svg?react";
import CircuitIcon from "#/icons/u-circuit.svg?react";
import PuzzlePieceIcon from "#/icons/u-puzzle-piece.svg?react";
import UserIcon from "#/icons/user.svg?react";
@@ -31,11 +33,21 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
to: "/settings/app",
text: "SETTINGS$NAV_APPLICATION",
},
{
icon: <SettingsIcon width={22} height={22} />,
to: "/settings/general",
text: "SETTINGS$NAV_GENERAL",
},
{
icon: <CircuitIcon width={22} height={22} />,
to: "/settings",
text: "COMMON$LANGUAGE_MODEL_LLM",
},
{
icon: <LockIcon width={22} height={22} />,
to: "/settings/security",
text: "SETTINGS$NAV_SECURITY",
},
{
icon: <MemoryIcon width={22} height={22} />,
to: "/settings/condenser",
@@ -79,11 +91,21 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
];
export const OSS_NAV_ITEMS: SettingsNavItem[] = [
{
icon: <SettingsIcon width={22} height={22} />,
to: "/settings/general",
text: "SETTINGS$NAV_GENERAL",
},
{
icon: <CircuitIcon width={22} height={22} />,
to: "/settings",
text: "SETTINGS$NAV_LLM",
},
{
icon: <LockIcon width={22} height={22} />,
to: "/settings/security",
text: "SETTINGS$NAV_SECURITY",
},
{
icon: <MemoryIcon width={22} height={22} />,
to: "/settings/condenser",

View File

@@ -137,7 +137,9 @@ export enum I18nKey {
SETTINGS$GITLAB = "SETTINGS$GITLAB",
SETTINGS$NAV_CONDENSER = "SETTINGS$NAV_CONDENSER",
SETTINGS$NAV_CRITIC = "SETTINGS$NAV_CRITIC",
SETTINGS$NAV_GENERAL = "SETTINGS$NAV_GENERAL",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
SETTINGS$NAV_SECURITY = "SETTINGS$NAV_SECURITY",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
GIT$GITLAB_API = "GIT$GITLAB_API",
GIT$PULL_REQUEST = "GIT$PULL_REQUEST",

View File

@@ -2191,6 +2191,22 @@
"de": "Kritiker",
"uk": "Критик"
},
"SETTINGS$NAV_GENERAL": {
"en": "General",
"ja": "一般",
"zh-CN": "通用",
"zh-TW": "通用",
"ko-KR": "일반",
"no": "Generelt",
"it": "Generale",
"pt": "Geral",
"es": "General",
"ar": "عام",
"fr": "Général",
"tr": "Genel",
"de": "Allgemein",
"uk": "Загальні"
},
"SETTINGS$NAV_LLM": {
"en": "LLM",
"ja": "LLM",
@@ -2207,6 +2223,22 @@
"de": "LLM",
"uk": "LLM"
},
"SETTINGS$NAV_SECURITY": {
"en": "Security",
"ja": "セキュリティ",
"zh-CN": "安全",
"zh-TW": "安全",
"ko-KR": "보안",
"no": "Sikkerhet",
"it": "Sicurezza",
"pt": "Segurança",
"es": "Seguridad",
"ar": "الأمان",
"fr": "Sécurité",
"tr": "Güvenlik",
"de": "Sicherheit",
"uk": "Безпека"
},
"GIT$MERGE_REQUEST": {
"en": "Merge Request",
"ja": "マージリクエスト",

View File

@@ -13,6 +13,8 @@ export default [
route("accept-tos", "routes/accept-tos.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("general", "routes/general-settings.tsx"),
route("security", "routes/security-settings.tsx"),
route("condenser", "routes/condenser-settings.tsx"),
route("critic", "routes/critic-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),

View File

@@ -0,0 +1,12 @@
import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page";
function GeneralSettingsScreen() {
return (
<SdkSectionPage
sectionKeys={["general"]}
testId="general-settings-screen"
/>
);
}
export default GeneralSettingsScreen;

View File

@@ -0,0 +1,12 @@
import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page";
function SecuritySettingsScreen() {
return (
<SdkSectionPage
sectionKeys={["security"]}
testId="security-settings-screen"
/>
);
}
export default SecuritySettingsScreen;

View File

@@ -156,8 +156,11 @@ export function buildInitialSettingsFormValues(
/** Determine which view tier to default to based on whether the user has
* overridden any non-critical settings. */
export function inferInitialView(settings: Settings): SettingsView {
const schema = settings.sdk_settings_schema;
export function inferInitialView(
settings: Settings,
schemaOverride?: SettingsSchema | null,
): SettingsView {
const schema = schemaOverride ?? settings.sdk_settings_schema;
if (!schema) {
return "basic";
}

View File

@@ -30,7 +30,7 @@ from openhands.server.user_auth import (
get_user_settings,
get_user_settings_store,
)
from openhands.storage.data_models.settings import SDK_LEGACY_FIELD_MAP, Settings
from openhands.storage.data_models.settings import Settings
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.llm import get_provider_api_base, is_openhands_model
@@ -82,21 +82,8 @@ def _extract_sdk_settings_values(
settings: Settings, schema: dict[str, Any] | None
) -> dict[str, Any]:
values = dict(settings.sdk_settings_values)
secret_field_keys = _get_sdk_secret_field_keys(schema)
for field_key in _get_sdk_field_keys(schema):
if field_key in secret_field_keys:
values[field_key] = None
continue
if field_key in values:
continue
legacy_field = SDK_LEGACY_FIELD_MAP.get(field_key)
if legacy_field is None or legacy_field not in Settings.model_fields:
continue
values[field_key] = getattr(settings, legacy_field)
for field_key in _get_sdk_secret_field_keys(schema):
values[field_key] = None
return values
@@ -112,11 +99,8 @@ def _apply_settings_payload(
sdk_settings_values = dict(settings.sdk_settings_values)
for key, value in payload.items():
legacy_field = SDK_LEGACY_FIELD_MAP.get(key)
if key in Settings.model_fields:
setattr(settings, key, value)
elif legacy_field in Settings.model_fields:
setattr(settings, legacy_field, value)
if key in sdk_field_keys and key not in secret_field_keys:
sdk_settings_values[key] = value

View File

@@ -20,14 +20,6 @@ from openhands.core.config.utils import load_openhands_config
from openhands.sdk.settings import AgentSettings
from openhands.storage.data_models.secrets import Secrets
SDK_LEGACY_FIELD_MAP: dict[str, str] = {
'llm.model': 'llm_model',
'llm.api_key': 'llm_api_key',
'llm.base_url': 'llm_base_url',
'condenser.enabled': 'enable_default_condenser',
'condenser.max_size': 'condenser_max_size',
}
def _assign_dotted_value(target: dict[str, Any], dotted_key: str, value: Any) -> None:
current = target
@@ -227,23 +219,8 @@ class Settings(BaseModel):
return self
def to_agent_settings(self) -> AgentSettings:
"""Build SDK AgentSettings from persisted OpenHands settings.
Values stored in ``sdk_settings_values`` take precedence. Legacy flat fields
are used as a fallback so older stored settings continue to work.
"""
"""Build SDK ``AgentSettings`` from persisted ``sdk_settings_values``."""
payload: dict[str, Any] = {}
sdk_values = dict(self.sdk_settings_values)
for key, value in sdk_values.items():
for key, value in self.sdk_settings_values.items():
_assign_dotted_value(payload, key, value)
for key, legacy_field in SDK_LEGACY_FIELD_MAP.items():
if key in sdk_values:
continue
legacy_value = getattr(self, legacy_field)
if legacy_value is None:
continue
_assign_dotted_value(payload, key, legacy_value)
return AgentSettings.model_validate(payload)

View File

@@ -134,14 +134,13 @@ class TestLiveStatusAppConversationService:
self.service._load_hooks_from_workspace = AsyncMock(return_value=None)
def _mock_user_to_agent_settings(self) -> AgentSettings:
return Settings(
llm_model=self.mock_user.llm_model,
llm_api_key=self.mock_user.llm_api_key,
llm_base_url=self.mock_user.llm_base_url,
enable_default_condenser=True,
condenser_max_size=self.mock_user.condenser_max_size,
sdk_settings_values=dict(self.mock_user.sdk_settings_values),
).to_agent_settings()
sdk_values = dict(self.mock_user.sdk_settings_values)
sdk_values.setdefault('llm.model', self.mock_user.llm_model or '')
if self.mock_user.llm_api_key:
sdk_values.setdefault('llm.api_key', self.mock_user.llm_api_key)
if self.mock_user.llm_base_url:
sdk_values.setdefault('llm.base_url', self.mock_user.llm_base_url)
return Settings(sdk_settings_values=sdk_values).to_agent_settings()
def test_apply_suggested_task_sets_prompt_and_trigger(self):
"""Test suggested task prompts populate initial message and trigger."""
@@ -2472,14 +2471,13 @@ class TestPluginHandling:
self.mock_sandbox.status = SandboxStatus.RUNNING
def _mock_user_to_agent_settings(self) -> AgentSettings:
return Settings(
llm_model=self.mock_user.llm_model,
llm_api_key=self.mock_user.llm_api_key,
llm_base_url=self.mock_user.llm_base_url,
enable_default_condenser=True,
condenser_max_size=self.mock_user.condenser_max_size,
sdk_settings_values=dict(self.mock_user.sdk_settings_values),
).to_agent_settings()
sdk_values = dict(self.mock_user.sdk_settings_values)
sdk_values.setdefault('llm.model', self.mock_user.llm_model or '')
if self.mock_user.llm_api_key:
sdk_values.setdefault('llm.api_key', self.mock_user.llm_api_key)
if self.mock_user.llm_base_url:
sdk_values.setdefault('llm.base_url', self.mock_user.llm_base_url)
return Settings(sdk_settings_values=sdk_values).to_agent_settings()
def test_construct_initial_message_with_plugin_params_no_plugins(self):
"""Test _construct_initial_message_with_plugin_params with no plugins returns original message."""

View File

@@ -108,17 +108,14 @@ def test_settings_preserve_sdk_settings_values():
}
def test_settings_to_agent_settings_prefers_sdk_values_and_legacy_fallbacks():
def test_settings_to_agent_settings_uses_sdk_values():
settings = Settings(
llm_model='legacy-model',
llm_api_key='legacy-key',
llm_base_url='https://legacy.example.com',
enable_default_condenser=True,
condenser_max_size=88,
sdk_settings_values={
'llm.model': 'sdk-model',
'llm.base_url': 'https://sdk.example.com',
'llm.litellm_extra_body': {'metadata': {'tier': 'enterprise'}},
'condenser.enabled': False,
'condenser.max_size': 88,
'critic.enabled': True,
'critic.mode': 'all_actions',
},
@@ -127,8 +124,7 @@ def test_settings_to_agent_settings_prefers_sdk_values_and_legacy_fallbacks():
agent_settings = settings.to_agent_settings()
assert agent_settings.llm.model == 'sdk-model'
assert agent_settings.llm.api_key.get_secret_value() == 'legacy-key'
assert agent_settings.llm.base_url == 'https://legacy.example.com'
assert agent_settings.llm.base_url == 'https://sdk.example.com'
assert agent_settings.llm.litellm_extra_body == {'metadata': {'tier': 'enterprise'}}
assert agent_settings.condenser.enabled is False
assert agent_settings.condenser.max_size == 88