mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): add contextual info messages on LLM settings page (org project) (#13460)
This commit is contained in:
@@ -13,7 +13,39 @@ import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
|||||||
import OptionService from "#/api/option-service/option-service.api";
|
import OptionService from "#/api/option-service/option-service.api";
|
||||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||||
import type { OrganizationMember } from "#/types/org";
|
import type { Organization, OrganizationMember } from "#/types/org";
|
||||||
|
|
||||||
|
/** Creates a mock Organization with default values for testing */
|
||||||
|
const createMockOrganization = (
|
||||||
|
overrides: Partial<Organization> & Pick<Organization, "id" | "name">,
|
||||||
|
): Organization => ({
|
||||||
|
contact_name: "",
|
||||||
|
contact_email: "",
|
||||||
|
conversation_expiration: 0,
|
||||||
|
agent: "CodeActAgent",
|
||||||
|
default_max_iterations: 20,
|
||||||
|
security_analyzer: "",
|
||||||
|
confirmation_mode: false,
|
||||||
|
default_llm_model: "",
|
||||||
|
default_llm_api_key_for_byor: "",
|
||||||
|
default_llm_base_url: "",
|
||||||
|
remote_runtime_resource_factor: 1,
|
||||||
|
enable_default_condenser: true,
|
||||||
|
billing_margin: 0,
|
||||||
|
enable_proactive_conversation_starters: false,
|
||||||
|
sandbox_base_container_image: "",
|
||||||
|
sandbox_runtime_container_image: "",
|
||||||
|
org_version: 1,
|
||||||
|
mcp_config: { tools: [], settings: {} },
|
||||||
|
search_api_key: null,
|
||||||
|
sandbox_api_key: null,
|
||||||
|
max_budget_per_task: 0,
|
||||||
|
enable_solvability_analysis: false,
|
||||||
|
v1_enabled: true,
|
||||||
|
credits: 0,
|
||||||
|
is_personal: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
// Mock react-router hooks
|
// Mock react-router hooks
|
||||||
const mockUseSearchParams = vi.fn();
|
const mockUseSearchParams = vi.fn();
|
||||||
@@ -1767,3 +1799,163 @@ describe("clientLoader permission checks", () => {
|
|||||||
expect(typeof clientLoader).toBe("function");
|
expect(typeof clientLoader).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Contextual info messages", () => {
|
||||||
|
it("should show admin message when user is an admin in a team organization", async () => {
|
||||||
|
// Arrange
|
||||||
|
const orgId = "team-org-1";
|
||||||
|
const adminMeData: OrganizationMember = {
|
||||||
|
org_id: orgId,
|
||||||
|
user_id: "1",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "admin",
|
||||||
|
status: "active",
|
||||||
|
llm_api_key: "",
|
||||||
|
max_iterations: 20,
|
||||||
|
llm_model: "",
|
||||||
|
llm_api_key_for_byor: null,
|
||||||
|
llm_base_url: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseConfig.mockReturnValue({
|
||||||
|
data: { app_mode: "saas" },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(organizationService, "getMe").mockResolvedValue(adminMeData);
|
||||||
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
createMockOrganization({
|
||||||
|
id: orgId,
|
||||||
|
name: "Team Org",
|
||||||
|
is_personal: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
currentOrgId: orgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderLlmSettingsScreen(orgId, adminMeData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("llm-settings-info-message"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
|
||||||
|
"SETTINGS$LLM_ADMIN_INFO",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show member message when user is a member in a team organization", async () => {
|
||||||
|
// Arrange
|
||||||
|
const orgId = "team-org-2";
|
||||||
|
const memberMeData: OrganizationMember = {
|
||||||
|
org_id: orgId,
|
||||||
|
user_id: "2",
|
||||||
|
email: "member@example.com",
|
||||||
|
role: "member",
|
||||||
|
status: "active",
|
||||||
|
llm_api_key: "",
|
||||||
|
max_iterations: 20,
|
||||||
|
llm_model: "",
|
||||||
|
llm_api_key_for_byor: null,
|
||||||
|
llm_base_url: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseConfig.mockReturnValue({
|
||||||
|
data: { app_mode: "saas" },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(organizationService, "getMe").mockResolvedValue(memberMeData);
|
||||||
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
createMockOrganization({
|
||||||
|
id: orgId,
|
||||||
|
name: "Team Org",
|
||||||
|
is_personal: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
currentOrgId: orgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderLlmSettingsScreen(orgId, memberMeData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("llm-settings-info-message"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
|
||||||
|
"SETTINGS$LLM_MEMBER_INFO",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show info message in personal workspace", async () => {
|
||||||
|
// Arrange
|
||||||
|
const orgId = "personal-org-1";
|
||||||
|
const ownerMeData: OrganizationMember = {
|
||||||
|
org_id: orgId,
|
||||||
|
user_id: "3",
|
||||||
|
email: "user@example.com",
|
||||||
|
role: "owner",
|
||||||
|
status: "active",
|
||||||
|
llm_api_key: "",
|
||||||
|
max_iterations: 20,
|
||||||
|
llm_model: "",
|
||||||
|
llm_api_key_for_byor: null,
|
||||||
|
llm_base_url: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseConfig.mockReturnValue({
|
||||||
|
data: { app_mode: "saas" },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(organizationService, "getMe").mockResolvedValue(ownerMeData);
|
||||||
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
createMockOrganization({ id: orgId, name: "Personal", is_personal: true }),
|
||||||
|
],
|
||||||
|
currentOrgId: orgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderLlmSettingsScreen(orgId, ownerMeData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("llm-settings-info-message"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show info message in OSS mode", async () => {
|
||||||
|
// Arrange
|
||||||
|
mockUseConfig.mockReturnValue({
|
||||||
|
data: { app_mode: "oss" },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderLlmSettingsScreen();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("llm-settings-info-message"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -434,6 +434,8 @@ export enum I18nKey {
|
|||||||
SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT",
|
SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT",
|
||||||
SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX",
|
SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX",
|
||||||
SETTINGS$LLM_BILLING_INFO = "SETTINGS$LLM_BILLING_INFO",
|
SETTINGS$LLM_BILLING_INFO = "SETTINGS$LLM_BILLING_INFO",
|
||||||
|
SETTINGS$LLM_ADMIN_INFO = "SETTINGS$LLM_ADMIN_INFO",
|
||||||
|
SETTINGS$LLM_MEMBER_INFO = "SETTINGS$LLM_MEMBER_INFO",
|
||||||
SETTINGS$SEE_PRICING_DETAILS = "SETTINGS$SEE_PRICING_DETAILS",
|
SETTINGS$SEE_PRICING_DETAILS = "SETTINGS$SEE_PRICING_DETAILS",
|
||||||
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
|
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
|
||||||
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
|
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
|
||||||
|
|||||||
@@ -6943,6 +6943,38 @@
|
|||||||
"de": "LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet.",
|
"de": "LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet.",
|
||||||
"uk": "Використання LLM оплачується за тарифами провайдерів без надбавки."
|
"uk": "Використання LLM оплачується за тарифами провайдерів без надбавки."
|
||||||
},
|
},
|
||||||
|
"SETTINGS$LLM_ADMIN_INFO": {
|
||||||
|
"en": "The LLM settings configured below will apply to all users of the organization.",
|
||||||
|
"ja": "以下に設定されたLLM設定は、組織のすべてのユーザーに適用されます。",
|
||||||
|
"zh-CN": "以下配置的LLM设置将应用于组织的所有用户。",
|
||||||
|
"zh-TW": "以下配置的LLM設定將適用於組織的所有用戶。",
|
||||||
|
"ko-KR": "아래에 구성된 LLM 설정은 조직의 모든 사용자에게 적용됩니다.",
|
||||||
|
"no": "LLM-innstillingene som er konfigurert nedenfor, vil gjelde for alle brukere i organisasjonen.",
|
||||||
|
"it": "Le impostazioni LLM configurate di seguito si applicheranno a tutti gli utenti dell'organizzazione.",
|
||||||
|
"pt": "As configurações de LLM configuradas abaixo serão aplicadas a todos os usuários da organização.",
|
||||||
|
"es": "La configuración de LLM configurada a continuación se aplicará a todos los usuarios de la organización.",
|
||||||
|
"ar": "ستنطبق إعدادات LLM المكونة أدناه على جميع مستخدمي المؤسسة.",
|
||||||
|
"fr": "Les paramètres LLM configurés ci-dessous s'appliqueront à tous les utilisateurs de l'organisation.",
|
||||||
|
"tr": "Aşağıda yapılandırılan LLM ayarları, kuruluştaki tüm kullanıcılara uygulanacaktır.",
|
||||||
|
"de": "Die unten konfigurierten LLM-Einstellungen gelten für alle Benutzer der Organisation.",
|
||||||
|
"uk": "Налаштування LLM, налаштовані нижче, будуть застосовані до всіх користувачів організації."
|
||||||
|
},
|
||||||
|
"SETTINGS$LLM_MEMBER_INFO": {
|
||||||
|
"en": "LLM settings are managed by your organization's administrator.",
|
||||||
|
"ja": "LLM設定は組織の管理者によって管理されています。",
|
||||||
|
"zh-CN": "LLM设置由您的组织管理员管理。",
|
||||||
|
"zh-TW": "LLM設定由您的組織管理員管理。",
|
||||||
|
"ko-KR": "LLM 설정은 조직 관리자가 관리합니다.",
|
||||||
|
"no": "LLM-innstillinger administreres av organisasjonens administrator.",
|
||||||
|
"it": "Le impostazioni LLM sono gestite dall'amministratore della tua organizzazione.",
|
||||||
|
"pt": "As configurações de LLM são gerenciadas pelo administrador da sua organização.",
|
||||||
|
"es": "La configuración de LLM es administrada por el administrador de su organización.",
|
||||||
|
"ar": "يتم إدارة إعدادات LLM بواسطة مسؤول مؤسستك.",
|
||||||
|
"fr": "Les paramètres LLM sont gérés par l'administrateur de votre organisation.",
|
||||||
|
"tr": "LLM ayarları kuruluşunuzun yöneticisi tarafından yönetilmektedir.",
|
||||||
|
"de": "LLM-Einstellungen werden vom Administrator Ihrer Organisation verwaltet.",
|
||||||
|
"uk": "Налаштування LLM керуються адміністратором вашої організації."
|
||||||
|
},
|
||||||
"SETTINGS$SEE_PRICING_DETAILS": {
|
"SETTINGS$SEE_PRICING_DETAILS": {
|
||||||
"en": "See pricing details.",
|
"en": "See pricing details.",
|
||||||
"ja": "価格詳細を見る。",
|
"ja": "価格詳細を見る。",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { getProviderId } from "#/utils/map-provider";
|
|||||||
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
|
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
|
||||||
import { useMe } from "#/hooks/query/use-me";
|
import { useMe } from "#/hooks/query/use-me";
|
||||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||||
|
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||||
|
|
||||||
interface OpenHandsApiKeyHelpProps {
|
interface OpenHandsApiKeyHelpProps {
|
||||||
testId: string;
|
testId: string;
|
||||||
@@ -74,12 +75,35 @@ function LlmSettingsScreen() {
|
|||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
const { hasPermission } = usePermission(me?.role ?? "member");
|
const { hasPermission } = usePermission(me?.role ?? "member");
|
||||||
|
const { isPersonalOrg, isTeamOrg } = useOrgTypeAndAccess();
|
||||||
|
|
||||||
// In OSS mode, user has full access (no permission restrictions)
|
// In OSS mode, user has full access (no permission restrictions)
|
||||||
// In SaaS mode, check role-based permissions (members can only view, owners and admins can edit)
|
// In SaaS mode, check role-based permissions (members can only view, owners and admins can edit)
|
||||||
const isOssMode = config?.app_mode === "oss";
|
const isOssMode = config?.app_mode === "oss";
|
||||||
const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings");
|
const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings");
|
||||||
|
|
||||||
|
// Determine the contextual info message based on workspace type and role
|
||||||
|
const getLlmSettingsInfoMessage = (): I18nKey | null => {
|
||||||
|
// No message in OSS mode (no organization context)
|
||||||
|
if (isOssMode) return null;
|
||||||
|
|
||||||
|
// No message for personal workspaces
|
||||||
|
if (isPersonalOrg) return null;
|
||||||
|
|
||||||
|
// Team org - show appropriate message based on role
|
||||||
|
if (isTeamOrg) {
|
||||||
|
const role = me?.role ?? "member";
|
||||||
|
if (role === "admin" || role === "owner") {
|
||||||
|
return I18nKey.SETTINGS$LLM_ADMIN_INFO;
|
||||||
|
}
|
||||||
|
return I18nKey.SETTINGS$LLM_MEMBER_INFO;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const llmInfoMessage = getLlmSettingsInfoMessage();
|
||||||
|
|
||||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||||
|
|
||||||
const [dirtyInputs, setDirtyInputs] = React.useState({
|
const [dirtyInputs, setDirtyInputs] = React.useState({
|
||||||
@@ -504,6 +528,14 @@ function LlmSettingsScreen() {
|
|||||||
className="flex flex-col h-full justify-between"
|
className="flex flex-col h-full justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
{llmInfoMessage && (
|
||||||
|
<p
|
||||||
|
data-testid="llm-settings-info-message"
|
||||||
|
className="text-sm text-tertiary-alt"
|
||||||
|
>
|
||||||
|
{t(llmInfoMessage)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<SettingsSwitch
|
<SettingsSwitch
|
||||||
testId="advanced-settings-switch"
|
testId="advanced-settings-switch"
|
||||||
defaultIsToggled={view === "advanced"}
|
defaultIsToggled={view === "advanced"}
|
||||||
|
|||||||
Reference in New Issue
Block a user