mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
Enable org LLM profiles in settings (#14715)
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LlmProfileSummary } from "#/api/settings-service/profiles-service.api";
|
||||
import { OrgLlmProfilesManager } from "#/components/features/settings/org-llm-profiles-manager";
|
||||
|
||||
type ProfilesList = {
|
||||
profiles: LlmProfileSummary[];
|
||||
active_profile: string | null;
|
||||
};
|
||||
|
||||
const profilesState: {
|
||||
data: ProfilesList | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
} = { data: undefined, isLoading: false, error: null };
|
||||
|
||||
const activateMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const renameMock = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/query/use-org-llm-profiles", () => ({
|
||||
useOrgLlmProfiles: () => profilesState,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-org-llm-profile-mutations", () => ({
|
||||
useActivateOrgLlmProfile: () => ({
|
||||
mutateAsync: activateMock,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteOrgLlmProfile: () => ({
|
||||
mutateAsync: deleteMock,
|
||||
isPending: false,
|
||||
}),
|
||||
useRenameOrgLlmProfile: () => ({
|
||||
mutateAsync: renameMock,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const sampleProfiles: ProfilesList = {
|
||||
profiles: [
|
||||
{
|
||||
name: "sonnet",
|
||||
model: "openhands/claude-sonnet-4-5-20250929",
|
||||
base_url: null,
|
||||
api_key_set: true,
|
||||
},
|
||||
{
|
||||
name: "opus",
|
||||
model: "openhands/claude-opus-4-7",
|
||||
base_url: null,
|
||||
api_key_set: true,
|
||||
},
|
||||
],
|
||||
active_profile: "sonnet",
|
||||
};
|
||||
|
||||
function renderManager({
|
||||
canManage,
|
||||
onAddProfile,
|
||||
onEditProfile,
|
||||
}: {
|
||||
canManage?: boolean;
|
||||
onAddProfile?: () => void;
|
||||
onEditProfile?: (profile: LlmProfileSummary) => void;
|
||||
} = {}) {
|
||||
return render(
|
||||
<OrgLlmProfilesManager
|
||||
orgId="org-1"
|
||||
canManage={canManage}
|
||||
onAddProfile={onAddProfile}
|
||||
onEditProfile={onEditProfile}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
profilesState.data = sampleProfiles;
|
||||
profilesState.isLoading = false;
|
||||
profilesState.error = null;
|
||||
activateMock.mockReset().mockResolvedValue(undefined);
|
||||
deleteMock.mockReset().mockResolvedValue(undefined);
|
||||
renameMock.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("OrgLlmProfilesManager", () => {
|
||||
it("renders Add LLM Profile when management is enabled", async () => {
|
||||
const onAddProfile = vi.fn();
|
||||
renderManager({ canManage: true, onAddProfile });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("add-llm-profile"));
|
||||
|
||||
expect(onAddProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders profiles without management controls when management is disabled", () => {
|
||||
renderManager({
|
||||
canManage: false,
|
||||
onAddProfile: vi.fn(),
|
||||
onEditProfile: vi.fn(),
|
||||
});
|
||||
|
||||
expect(screen.getByText("sonnet")).toBeInTheDocument();
|
||||
expect(screen.getByText("opus")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-llm-profile")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("profile-menu-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -42,13 +42,13 @@ describe("ProfileActionsMenu", () => {
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables Set as active when the profile is already active", () => {
|
||||
it("disables Set as default when the profile is already default", () => {
|
||||
renderMenu({ isActive: true });
|
||||
|
||||
expect(screen.getByTestId("profile-set-active")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables Set as active while an activation is in flight", () => {
|
||||
it("disables Set as default while an activation is in flight", () => {
|
||||
renderMenu({ isActivating: true });
|
||||
|
||||
expect(screen.getByTestId("profile-set-active")).toBeDisabled();
|
||||
|
||||
@@ -58,4 +58,13 @@ describe("ProfileRow", () => {
|
||||
await user.click(screen.getByTestId("profile-menu-trigger"));
|
||||
expect(screen.getByTestId("profile-edit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions menu trigger when management is disabled", () => {
|
||||
renderRow({ canManage: false });
|
||||
|
||||
expect(screen.getByText("openai_gpt-4o")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("profile-menu-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,4 +86,26 @@ describe("ProfilesBody", () => {
|
||||
expect(rows[0]).toHaveTextContent("SETTINGS$PROFILE_ACTIVE_BADGE");
|
||||
expect(rows[1]).not.toHaveTextContent("SETTINGS$PROFILE_ACTIVE_BADGE");
|
||||
});
|
||||
|
||||
it("renders profiles without action menus when management is disabled", () => {
|
||||
render(
|
||||
<ProfilesBody
|
||||
isLoading={false}
|
||||
loadError={null}
|
||||
profiles={profiles}
|
||||
active="p1"
|
||||
onActivate={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
isActivating={false}
|
||||
canManage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("profile-row")).toHaveLength(2);
|
||||
expect(
|
||||
screen.queryByTestId("profile-menu-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import OrgProfilesService from "#/api/organization-service/org-profiles-service.api";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import ProfilesService from "#/api/settings-service/profiles-service.api";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
@@ -30,6 +31,17 @@ vi.mock("#/api/settings-service/profiles-service.api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/api/organization-service/org-profiles-service.api", () => ({
|
||||
default: {
|
||||
listProfiles: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
saveProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
activateProfile: vi.fn(),
|
||||
renameProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function resetProfilesServiceDefaults() {
|
||||
vi.mocked(ProfilesService.listProfiles)
|
||||
.mockReset()
|
||||
@@ -48,6 +60,30 @@ function resetProfilesServiceDefaults() {
|
||||
.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
function resetOrgProfilesServiceDefaults() {
|
||||
vi.mocked(OrgProfilesService.listProfiles)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ profiles: [], active_profile: null });
|
||||
vi.mocked(OrgProfilesService.getProfile)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
name: "openai_gpt-4o",
|
||||
llm: { model: "openai/gpt-4o" },
|
||||
});
|
||||
vi.mocked(OrgProfilesService.saveProfile)
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined);
|
||||
vi.mocked(OrgProfilesService.deleteProfile)
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined);
|
||||
vi.mocked(OrgProfilesService.activateProfile)
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined);
|
||||
vi.mocked(OrgProfilesService.renameProfile)
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
// Stub the profile mutation hooks so auto-save doesn't invalidate the
|
||||
// settings query — tests in this file pin exact getSettings call counts.
|
||||
// The mutateAsync implementations forward to the service mock so the
|
||||
@@ -215,7 +251,7 @@ async function renderLlmSettingsScreen({
|
||||
meData?: OrganizationMember;
|
||||
organizations?: Organization[];
|
||||
scope?: "personal" | "org";
|
||||
// Personal scope now lands on the Available Models list by default; set
|
||||
// Profile-enabled scopes land on the Available Models list by default; set
|
||||
// ``view`` to ``"form"`` (the default) to auto-click into the SDK form
|
||||
// so existing form-oriented assertions keep working unchanged, or to
|
||||
// ``"profiles"`` to test the list view itself.
|
||||
@@ -250,12 +286,14 @@ async function renderLlmSettingsScreen({
|
||||
const rendered = render(<LlmSettingsScreen scope={scope} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
|
||||
if (scope === "personal" && view === "form") {
|
||||
if (view === "form") {
|
||||
await userEvent.click(await screen.findByTestId("add-llm-profile"));
|
||||
}
|
||||
|
||||
@@ -265,6 +303,7 @@ async function renderLlmSettingsScreen({
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetProfilesServiceDefaults();
|
||||
resetOrgProfilesServiceDefaults();
|
||||
resetTestHandlersMockSettings();
|
||||
mockUseSearchParams.mockReturnValue([{ get: () => null }, vi.fn()]);
|
||||
mockUseConfig.mockReturnValue({
|
||||
@@ -312,7 +351,7 @@ describe("LlmSettingsScreen", () => {
|
||||
it("shows Advanced and All toggles in OSS mode for the default LLM route schema", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
|
||||
|
||||
renderLlmSettingsScreen({ appMode: "oss" });
|
||||
await renderLlmSettingsScreen({ appMode: "oss" });
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
expect(
|
||||
@@ -332,7 +371,7 @@ describe("LlmSettingsScreen", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
renderLlmSettingsScreen({ appMode: "saas", scope: "org" });
|
||||
await renderLlmSettingsScreen({ appMode: "saas", scope: "org" });
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
expect(
|
||||
@@ -914,7 +953,7 @@ describe("LlmSettingsScreen", () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen({ appMode: "oss" });
|
||||
await renderLlmSettingsScreen({ appMode: "oss" });
|
||||
|
||||
await screen.findByTestId("llm-settings-form-basic");
|
||||
|
||||
@@ -1196,9 +1235,20 @@ describe("LlmSettingsScreen", () => {
|
||||
expect(payload.settings).not.toHaveProperty("search_api_key");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOrganizationSettingsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getOrganizationSettingsSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-llm-profile")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-basic"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-advanced"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId("add-llm-profile"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -1767,6 +1817,62 @@ describe("LlmSettingsScreen", () => {
|
||||
});
|
||||
|
||||
describe("Role-based permissions", () => {
|
||||
describe("Org default profiles", () => {
|
||||
it("shows org profiles and management controls for admins", async () => {
|
||||
vi.mocked(OrgProfilesService.listProfiles).mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
name: "sonnet",
|
||||
model: "openhands/claude-sonnet-4-5-20250929",
|
||||
base_url: null,
|
||||
api_key_set: true,
|
||||
},
|
||||
],
|
||||
active_profile: "sonnet",
|
||||
});
|
||||
|
||||
await renderLlmSettingsScreen({
|
||||
appMode: "saas",
|
||||
scope: "org",
|
||||
organizationId: "3",
|
||||
meData: buildOrganizationMember({ org_id: "3", role: "admin" }),
|
||||
view: "profiles",
|
||||
});
|
||||
|
||||
expect(await screen.findByText("sonnet")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("add-llm-profile")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("profile-menu-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows org profiles without management controls for members", async () => {
|
||||
vi.mocked(OrgProfilesService.listProfiles).mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
name: "sonnet",
|
||||
model: "openhands/claude-sonnet-4-5-20250929",
|
||||
base_url: null,
|
||||
api_key_set: true,
|
||||
},
|
||||
],
|
||||
active_profile: "sonnet",
|
||||
});
|
||||
|
||||
await renderLlmSettingsScreen({
|
||||
appMode: "saas",
|
||||
scope: "org",
|
||||
organizationId: "2",
|
||||
meData: buildOrganizationMember({ org_id: "2", role: "member" }),
|
||||
view: "profiles",
|
||||
});
|
||||
|
||||
expect(await screen.findByText("sonnet")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-llm-profile")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("profile-menu-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Member role (personal overrides allowed)", () => {
|
||||
it("should keep all input fields enabled in basic view", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
@@ -2135,10 +2241,7 @@ describe("LlmSettingsScreen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT auto-save a profile on the org-default settings screen", async () => {
|
||||
// Org defaults reuse this screen with scope="org". Profiles are a
|
||||
// per-user feature, so touching the profiles endpoints here would
|
||||
// incorrectly spawn profiles on the signed-in user's settings.
|
||||
it("saves + activates an org profile on the org-default settings screen", async () => {
|
||||
vi.spyOn(
|
||||
organizationService,
|
||||
"getOrganizationSettings",
|
||||
@@ -2164,6 +2267,10 @@ describe("LlmSettingsScreen", () => {
|
||||
meData: buildOrganizationMember({ org_id: "3", role: "admin" }),
|
||||
});
|
||||
|
||||
await userEvent.type(
|
||||
await screen.findByTestId("llm-profile-name-input"),
|
||||
"team-profile",
|
||||
);
|
||||
await userEvent.type(
|
||||
await screen.findByTestId("llm-api-key-input"),
|
||||
"test-api-key",
|
||||
@@ -2173,6 +2280,19 @@ describe("LlmSettingsScreen", () => {
|
||||
await waitFor(() => {
|
||||
expect(organizationService.saveOrganizationSettings).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(OrgProfilesService.saveProfile).toHaveBeenCalledWith(
|
||||
"3",
|
||||
"team-profile",
|
||||
{ include_secrets: true },
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(OrgProfilesService.activateProfile).toHaveBeenCalledWith(
|
||||
"3",
|
||||
"team-profile",
|
||||
);
|
||||
});
|
||||
expect(ProfilesService.saveProfile).not.toHaveBeenCalled();
|
||||
expect(ProfilesService.activateProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2244,7 +2364,7 @@ describe("LlmSettingsScreen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render the profile-name input on the org-default settings screen", async () => {
|
||||
it("renders the profile-name input on the org-default profile form for admins", async () => {
|
||||
vi.spyOn(
|
||||
organizationService,
|
||||
"getOrganizationSettings",
|
||||
@@ -2261,12 +2381,8 @@ describe("LlmSettingsScreen", () => {
|
||||
meData: buildOrganizationMember({ org_id: "3", role: "admin" }),
|
||||
});
|
||||
|
||||
// Wait for the form to render (org-defaults takes the same screen
|
||||
// but should never offer to name a profile).
|
||||
await screen.findByTestId("llm-api-key-input");
|
||||
expect(
|
||||
screen.queryByTestId("llm-profile-name-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("llm-profile-name-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swallows profile-save failures so the user still sees the settings-saved toast", async () => {
|
||||
|
||||
@@ -13,12 +13,14 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface OrgLlmProfilesManagerProps {
|
||||
orgId: string;
|
||||
canManage?: boolean;
|
||||
onAddProfile?: () => void;
|
||||
onEditProfile?: (profile: LlmProfileSummary) => void;
|
||||
}
|
||||
|
||||
export function OrgLlmProfilesManager({
|
||||
orgId,
|
||||
canManage = true,
|
||||
onAddProfile,
|
||||
onEditProfile,
|
||||
}: OrgLlmProfilesManagerProps) {
|
||||
@@ -52,7 +54,7 @@ export function OrgLlmProfilesManager({
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
{t(I18nKey.SETTINGS$AVAILABLE_PROFILES)}
|
||||
</h2>
|
||||
{onAddProfile ? (
|
||||
{canManage && onAddProfile ? (
|
||||
<BrandButton
|
||||
testId="add-llm-profile"
|
||||
type="button"
|
||||
@@ -75,6 +77,7 @@ export function OrgLlmProfilesManager({
|
||||
onRename={setProfileToRename}
|
||||
onDelete={setProfileToDelete}
|
||||
isActivating={activateProfile.isPending}
|
||||
canManage={canManage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ProfileRowProps {
|
||||
onRename: (profile: LlmProfileSummary) => void;
|
||||
onDelete: (profile: LlmProfileSummary) => void;
|
||||
isActivating: boolean;
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
export function ProfileRow({
|
||||
@@ -24,6 +25,7 @@ export function ProfileRow({
|
||||
onRename,
|
||||
onDelete,
|
||||
isActivating,
|
||||
canManage = true,
|
||||
}: ProfileRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -57,28 +59,30 @@ export function ProfileRow({
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-label={t(I18nKey.SETTINGS$PROFILE_MENU)}
|
||||
className="cursor-pointer text-gray-300 hover:text-white p-2 border border-tertiary rounded-md"
|
||||
data-testid="profile-menu-trigger"
|
||||
>
|
||||
<ThreeDotsVerticalIcon width={16} height={16} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<ProfileActionsMenu
|
||||
onEdit={() => onEdit(profile)}
|
||||
onRename={() => onRename(profile)}
|
||||
onSetActive={() => onActivate(profile.name)}
|
||||
onDelete={() => onDelete(profile)}
|
||||
isActive={isActive}
|
||||
isActivating={isActivating}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-label={t(I18nKey.SETTINGS$PROFILE_MENU)}
|
||||
className="cursor-pointer text-gray-300 hover:text-white p-2 border border-tertiary rounded-md"
|
||||
data-testid="profile-menu-trigger"
|
||||
>
|
||||
<ThreeDotsVerticalIcon width={16} height={16} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<ProfileActionsMenu
|
||||
onEdit={() => onEdit(profile)}
|
||||
onRename={() => onRename(profile)}
|
||||
onSetActive={() => onActivate(profile.name)}
|
||||
onDelete={() => onDelete(profile)}
|
||||
isActive={isActive}
|
||||
isActivating={isActivating}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ProfilesBodyProps {
|
||||
onRename: (profile: LlmProfileSummary) => void;
|
||||
onDelete: (profile: LlmProfileSummary) => void;
|
||||
isActivating: boolean;
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
export function ProfilesBody({
|
||||
@@ -27,6 +28,7 @@ export function ProfilesBody({
|
||||
onRename,
|
||||
onDelete,
|
||||
isActivating,
|
||||
canManage = true,
|
||||
}: ProfilesBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -63,6 +65,7 @@ export function ProfilesBody({
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
isActivating={isActivating}
|
||||
canManage={canManage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -25347,7 +25347,7 @@
|
||||
"uk": "Не вдалося завантажити профілі. Спробуйте ще раз."
|
||||
},
|
||||
"SETTINGS$PROFILE_ACTIVE_BADGE": {
|
||||
"en": "Active",
|
||||
"en": "Default",
|
||||
"ja": "アクティブ",
|
||||
"zh-CN": "活动",
|
||||
"zh-TW": "使用中",
|
||||
@@ -25483,7 +25483,7 @@
|
||||
"uk": "Профіль \"{{name}}\" видалено"
|
||||
},
|
||||
"SETTINGS$PROFILE_ACTIVATED": {
|
||||
"en": "Switched to profile \"{{name}}\"",
|
||||
"en": "Default profile set to \"{{name}}\"",
|
||||
"ja": "プロファイル\"{{name}}\"に切り替えました",
|
||||
"zh-CN": "已切换到配置文件 \"{{name}}\"",
|
||||
"zh-TW": "已切換到設定檔 \"{{name}}\"",
|
||||
@@ -25568,7 +25568,7 @@
|
||||
"uk": "Редагувати"
|
||||
},
|
||||
"SETTINGS$PROFILE_SET_ACTIVE": {
|
||||
"en": "Set as active",
|
||||
"en": "Set as default",
|
||||
"ja": "アクティブに設定",
|
||||
"zh-CN": "设为活动",
|
||||
"zh-TW": "設為使用中",
|
||||
|
||||
@@ -45,6 +45,8 @@ import { OrgLlmProfilesManager } from "#/components/features/settings/org-llm-pr
|
||||
import { ProfileNameInput } from "#/components/features/settings/profile-name-input";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||
|
||||
const LLM_EXCLUDED_KEYS = new Set(["llm.model", "llm.api_key", "llm.base_url"]);
|
||||
|
||||
@@ -112,7 +114,9 @@ export function LlmSettingsScreen({
|
||||
settings?.agent_settings_schema,
|
||||
);
|
||||
const { data: config } = useConfig();
|
||||
const { isPersonalOrg, organizationId } = useOrgTypeAndAccess();
|
||||
const { organizationId } = useOrgTypeAndAccess();
|
||||
const { data: me } = useMe();
|
||||
const { hasPermission } = usePermission(me?.role ?? "member");
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
|
||||
null,
|
||||
@@ -127,7 +131,7 @@ export function LlmSettingsScreen({
|
||||
const activateProfile = useActivateLlmProfile();
|
||||
const renameProfile = useRenameLlmProfile();
|
||||
|
||||
// Org profile hooks (for SaaS mode with personal orgs)
|
||||
// Org profile hooks (for org defaults)
|
||||
const saveOrgProfile = useSaveOrgLlmProfile(organizationId);
|
||||
const activateOrgProfile = useActivateOrgLlmProfile(organizationId);
|
||||
const renameOrgProfile = useRenameOrgLlmProfile(organizationId);
|
||||
@@ -135,11 +139,11 @@ export function LlmSettingsScreen({
|
||||
// Controls whether the LLM form or the Profiles list is shown. Flipping
|
||||
// this unmounts the inactive branch, so the SdkSectionPage re-hydrates
|
||||
// its view from ``initialViewHint`` when coming back from profiles.
|
||||
// Enable profiles for:
|
||||
// - Personal scope (OSS mode)
|
||||
// - Org scope with personal org (SaaS mode)
|
||||
const shouldShowProfilesForScope =
|
||||
scope === "personal" || (scope === "org" && isPersonalOrg);
|
||||
// Enable profiles for personal settings and org defaults. Org members can
|
||||
// view org profiles, but only admins/owners can create or manage them.
|
||||
const shouldShowProfilesForScope = scope === "personal" || scope === "org";
|
||||
const canManageProfilesForScope =
|
||||
scope === "personal" || hasPermission("edit_llm_settings");
|
||||
const [showProfiles, setShowProfiles] = React.useState(
|
||||
shouldShowProfilesForScope,
|
||||
);
|
||||
@@ -158,10 +162,8 @@ export function LlmSettingsScreen({
|
||||
const [initialViewHint, setInitialViewHint] =
|
||||
React.useState<SettingsView | null>(null);
|
||||
|
||||
// Show profiles view for personal scope OR org scope with personal org
|
||||
const isProfilesView = shouldShowProfilesForScope && showProfiles;
|
||||
// Use org-scoped profile operations when in org scope
|
||||
const isOrgProfileMode = scope === "org" && isPersonalOrg;
|
||||
const isOrgProfileMode = scope === "org";
|
||||
|
||||
const defaultModel = String(
|
||||
(DEFAULT_SETTINGS.agent_settings?.llm as Record<string, unknown>)?.model ??
|
||||
@@ -306,7 +308,7 @@ export function LlmSettingsScreen({
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
|
||||
{shouldShowProfilesForScope ? (
|
||||
{canManageProfilesForScope ? (
|
||||
<ProfileNameInput
|
||||
testId="llm-profile-name-input"
|
||||
ruleTestId="llm-profile-name-rule"
|
||||
@@ -393,7 +395,7 @@ export function LlmSettingsScreen({
|
||||
scope,
|
||||
selectedProvider,
|
||||
settings?.llm_api_key_set,
|
||||
shouldShowProfilesForScope,
|
||||
canManageProfilesForScope,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -465,16 +467,14 @@ export function LlmSettingsScreen({
|
||||
: null;
|
||||
const name = userName ?? derivedName;
|
||||
|
||||
// Auto-saved profiles for:
|
||||
// - Personal scope (OSS mode)
|
||||
// - Org scope with personal org (SaaS mode)
|
||||
const shouldSaveProfile =
|
||||
(scope === "personal" || (scope === "org" && isPersonalOrg)) && name;
|
||||
canManageProfilesForScope &&
|
||||
(scope === "personal" || (scope === "org" && organizationId)) &&
|
||||
name;
|
||||
|
||||
if (shouldSaveProfile) {
|
||||
try {
|
||||
// Use org profile hooks for org scope, personal hooks for personal scope
|
||||
const useOrgHooks = scope === "org" && isPersonalOrg;
|
||||
const useOrgHooks = scope === "org";
|
||||
|
||||
// Editing an existing profile and renaming it via the form should
|
||||
// rename the record in place rather than spawning a new one and
|
||||
@@ -521,8 +521,9 @@ export function LlmSettingsScreen({
|
||||
}, [
|
||||
activateProfile,
|
||||
activateOrgProfile,
|
||||
canManageProfilesForScope,
|
||||
initialProfileName,
|
||||
isPersonalOrg,
|
||||
organizationId,
|
||||
profileName,
|
||||
renameProfile,
|
||||
renameOrgProfile,
|
||||
@@ -539,13 +540,22 @@ export function LlmSettingsScreen({
|
||||
};
|
||||
|
||||
if (isProfilesView) {
|
||||
// Use org profiles manager for personal orgs in SaaS mode
|
||||
if (isOrgProfileMode && organizationId) {
|
||||
if (isOrgProfileMode) {
|
||||
if (!organizationId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<OrgLlmProfilesManager
|
||||
orgId={organizationId}
|
||||
onAddProfile={() => openForm(null)}
|
||||
onEditProfile={(profile) => openForm(null, profile.name)}
|
||||
canManage={canManageProfilesForScope}
|
||||
onAddProfile={
|
||||
canManageProfilesForScope ? () => openForm(null) : undefined
|
||||
}
|
||||
onEditProfile={
|
||||
canManageProfilesForScope
|
||||
? (profile) => openForm(null, profile.name)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -558,9 +568,9 @@ export function LlmSettingsScreen({
|
||||
);
|
||||
}
|
||||
|
||||
// Sub-page back affordance when profiles are enabled (personal scope or
|
||||
// personal org). Replaces the previous "Profiles" trailing action so the
|
||||
// form view follows the second-level settings pattern.
|
||||
// Sub-page back affordance when profiles are enabled. Replaces the previous
|
||||
// "Profiles" trailing action so the form view follows the second-level
|
||||
// settings pattern.
|
||||
const backToProfiles = shouldShowProfilesForScope ? (
|
||||
<button
|
||||
data-testid="llm-back-to-profiles"
|
||||
@@ -596,7 +606,7 @@ export function LlmSettingsScreen({
|
||||
// settings fields being dirty. This matters in SaaS managed mode, where
|
||||
// the model is fixed and there's no editable API key, leaving the form
|
||||
// pristine and Save stuck disabled.
|
||||
extraDirty={shouldShowProfilesForScope}
|
||||
extraDirty={canManageProfilesForScope}
|
||||
onSaveSuccess={handleSaveSuccess}
|
||||
getInitialView={getInitialView}
|
||||
forceShowAdvancedView
|
||||
|
||||
Reference in New Issue
Block a user