refactor(frontend): settings modal (#1256)

* allow arrow functions for components

* initial commit - BaseModal

* initial commit - SettingsForm

* extend tests and component

* extend to support language

* refactor tests

* move files and separate component/tests

* extend functionality

* refactor

* major refactor and flip flops

* add tests

* fix styles and names

* add loading state

* remove old SettingModal

* refactor component into smaller ones

* fix model input

* revert eslint rule to allow multiple function definitions for components and remove unused helper function

* add new i18n key for language placeholder

---------

Co-authored-by: Robert Brennan <accounts@rbren.io>
This commit is contained in:
sp.wack 2024-04-22 03:54:29 +03:00 committed by GitHub
parent 454e9613b0
commit 1f2a845feb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 757 additions and 163 deletions

View File

@ -43,7 +43,7 @@
"required": {
"some": [ "nesting", "id" ]
}
}],
}],
"react/no-array-index-key": "off"
},"parserOptions": {
"project": ["**/tsconfig.json"]

View File

@ -1,18 +1,19 @@
import React, { useEffect, useState } from "react";
import "./App.css";
import { Toaster } from "react-hot-toast";
import { useDisclosure } from "@nextui-org/react";
import CogTooth from "./assets/cog-tooth";
import ChatInterface from "./components/ChatInterface";
import Errors from "./components/Errors";
import LoadMessageModal from "./components/LoadMessageModal";
import { Container, Orientation } from "./components/Resizable";
import SettingModal from "./components/SettingModal";
import Terminal from "./components/Terminal";
import Workspace from "./components/Workspace";
import { fetchMsgTotal } from "./services/session";
import { initializeAgent } from "./services/settingsService";
import Socket from "./services/socket";
import { ResFetchMsgTotal } from "./types/ResponseType";
import SettingsModal from "./components/settings/SettingsModal";
interface Props {
setSettingOpen: (isOpen: boolean) => void;
@ -35,10 +36,11 @@ function LeftNav({ setSettingOpen }: Props): JSX.Element {
let initOnce = false;
function App(): JSX.Element {
const [settingOpen, setSettingOpen] = useState(false);
const [isWarned, setIsWarned] = useState(false);
const [loadMsgWarning, setLoadMsgWarning] = useState(false);
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const getMsgTotal = () => {
if (isWarned) return;
fetchMsgTotal()
@ -62,14 +64,10 @@ function App(): JSX.Element {
getMsgTotal();
}, []);
const handleCloseModal = () => {
setSettingOpen(false);
};
return (
<div className="h-screen w-screen flex flex-col">
<div className="flex grow bg-neutral-900 text-white min-h-0">
<LeftNav setSettingOpen={setSettingOpen} />
<LeftNav setSettingOpen={onOpen} />
<Container
orientation={Orientation.VERTICAL}
className="grow p-3 py-3 pr-3 min-w-0"
@ -93,7 +91,7 @@ function App(): JSX.Element {
{/* This div is for the footer that will be added later
<div className="h-8 w-full border-t border-border px-2" />
*/}
<SettingModal isOpen={settingOpen} onClose={handleCloseModal} />
<SettingsModal isOpen={isOpen} onOpenChange={onOpenChange} />
<LoadMessageModal
isOpen={loadMsgWarning}
onClose={() => setLoadMsgWarning(false)}

View File

@ -1,154 +0,0 @@
import React, { useEffect, useState } from "react";
import {
Autocomplete,
AutocompleteItem,
Button,
Select,
SelectItem,
} from "@nextui-org/react";
import { KeyboardEvent } from "@react-types/shared/src/events";
import { useTranslation } from "react-i18next";
import {
fetchAgents,
fetchModels,
saveSettings,
getCurrentSettings,
Settings,
} from "../services/settingsService";
import { I18nKey } from "../i18n/declaration";
import { AvailableLanguages } from "../i18n";
import { ArgConfigType } from "../types/ConfigType";
import ODModal from "./ODModal";
interface Props {
isOpen: boolean;
onClose: () => void;
}
function InnerSettingModal({ isOpen, onClose }: Props): JSX.Element {
const currentSettings: Settings = getCurrentSettings();
const [model, setModel] = useState(currentSettings[ArgConfigType.LLM_MODEL]);
const [inputModel, setInputModel] = useState(
currentSettings[ArgConfigType.LLM_MODEL],
);
const [agent, setAgent] = useState(currentSettings[ArgConfigType.AGENT]);
const [language, setLanguage] = useState(
currentSettings[ArgConfigType.LANGUAGE],
);
const { t } = useTranslation();
const [supportedModels, setSupportedModels] = useState([]);
const [supportedAgents, setSupportedAgents] = useState([]);
useEffect(() => {
fetchModels().then((fetchedModels) => {
const sortedModels = fetchedModels.sort(); // Sorting the models alphabetically
setSupportedModels(sortedModels);
});
fetchAgents().then((fetchedAgents) => {
setSupportedAgents(fetchedAgents);
});
}, []);
const handleSaveCfg = () => {
saveSettings({
[ArgConfigType.LLM_MODEL]: model ?? inputModel,
[ArgConfigType.AGENT]: agent,
[ArgConfigType.LANGUAGE]: language,
});
onClose();
};
const customFilter = (item: string, input: string) =>
item.toLowerCase().includes(input.toLowerCase());
return (
<ODModal
isOpen={isOpen}
onClose={onClose}
title={t(I18nKey.CONFIGURATION$MODAL_TITLE)}
subtitle={t(I18nKey.CONFIGURATION$MODAL_SUB_TITLE)}
hideCloseButton
backdrop="blur"
size="sm"
primaryAction={
<Button className="bg-primary rounded-small" onPress={handleSaveCfg}>
{t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL)}
</Button>
}
secondaryAction={
<Button className="bg-neutral-500 rounded-small" onPress={onClose}>
{t(I18nKey.CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL)}
</Button>
}
>
<>
<Autocomplete
defaultItems={supportedModels.map((v: string) => ({
label: v,
value: v,
}))}
label={t(I18nKey.CONFIGURATION$MODEL_SELECT_LABEL)}
placeholder={t(I18nKey.CONFIGURATION$MODEL_SELECT_PLACEHOLDER)}
selectedKey={model}
onSelectionChange={(key) => {
setModel(key as string);
}}
onInputChange={(e) => setInputModel(e)}
onKeyDown={(e: KeyboardEvent) => e.continuePropagation()}
defaultFilter={customFilter}
defaultInputValue={inputModel}
allowsCustomValue
>
{(item: { label: string; value: string }) => (
<AutocompleteItem key={item.value} value={item.value}>
{item.label}
</AutocompleteItem>
)}
</Autocomplete>
<Autocomplete
defaultItems={supportedAgents.map((v: string) => ({
label: v,
value: v,
}))}
label={t(I18nKey.CONFIGURATION$AGENT_SELECT_LABEL)}
placeholder={t(I18nKey.CONFIGURATION$AGENT_SELECT_PLACEHOLDER)}
defaultSelectedKey={agent}
onSelectionChange={(key) => {
setAgent(key as string);
}}
onKeyDown={(e: KeyboardEvent) => e.continuePropagation()}
defaultFilter={customFilter}
>
{(item: { label: string; value: string }) => (
<AutocompleteItem key={item.value} value={item.value}>
{item.label}
</AutocompleteItem>
)}
</Autocomplete>
<Select
selectionMode="single"
onChange={(e) => setLanguage(e.target.value)}
selectedKeys={[language || ""]}
label={t(I18nKey.CONFIGURATION$LANGUAGE_SELECT_LABEL)}
>
{AvailableLanguages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</Select>
</>
</ODModal>
);
}
function SettingModal({ isOpen, onClose }: Props): JSX.Element {
// Do not render the modal if it is not open, prevents reading empty from localStorage after initialization
if (!isOpen) return <div />;
return <InnerSettingModal isOpen={isOpen} onClose={onClose} />;
}
export default SettingModal;

View File

@ -0,0 +1,98 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { act } from "react-dom/test-utils";
import BaseModal from "./BaseModal";
describe("BaseModal", () => {
it("should render if the modal is open", () => {
const { rerender } = render(
<BaseModal isOpen={false} onOpenChange={vi.fn} title="Settings" />,
);
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
rerender(<BaseModal title="Settings" onOpenChange={vi.fn} isOpen />);
expect(screen.getByText("Settings")).toBeInTheDocument();
});
it("should render an optional subtitle", () => {
render(
<BaseModal
isOpen
onOpenChange={vi.fn}
title="Settings"
subtitle="Subtitle"
/>,
);
expect(screen.getByText("Subtitle")).toBeInTheDocument();
});
it("should render actions", () => {
const onPrimaryClickMock = vi.fn();
const onSecondaryClickMock = vi.fn();
const primaryAction = {
action: onPrimaryClickMock,
label: "Save",
};
const secondaryAction = {
action: onSecondaryClickMock,
label: "Cancel",
};
render(
<BaseModal
isOpen
onOpenChange={vi.fn}
title="Settings"
actions={[primaryAction, secondaryAction]}
/>,
);
expect(screen.getByText("Save")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
act(() => {
userEvent.click(screen.getByText("Save"));
});
expect(onPrimaryClickMock).toHaveBeenCalledTimes(1);
act(() => {
userEvent.click(screen.getByText("Cancel"));
});
expect(onSecondaryClickMock).toHaveBeenCalledTimes(1);
});
it("should close the modal after an action is performed", () => {
const onOpenChangeMock = vi.fn();
render(
<BaseModal
isOpen
onOpenChange={onOpenChangeMock}
title="Settings"
actions={[
{
label: "Save",
action: () => {},
closeAfterAction: true,
},
]}
/>,
);
act(() => {
userEvent.click(screen.getByText("Save"));
});
expect(onOpenChangeMock).toHaveBeenCalledTimes(1);
});
it("should render children", () => {
render(
<BaseModal isOpen onOpenChange={vi.fn} title="Settings">
<div>Children</div>
</BaseModal>,
);
expect(screen.getByText("Children")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,66 @@
import React from "react";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/react";
import { Action, FooterContent } from "./FooterContent";
import { HeaderContent } from "./HeaderContent";
interface BaseModalProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
title: string;
subtitle?: string;
actions?: Action[];
children?: React.ReactNode;
}
function BaseModal({
isOpen,
onOpenChange,
title,
subtitle,
actions,
children,
}: BaseModalProps) {
return (
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
title={title}
backdrop="blur"
hideCloseButton
size="sm"
className="bg-neutral-900 rounded-large"
>
<ModalContent className="max-w-[24rem] p-[40px]">
{(closeModal) => (
<>
<ModalHeader className="flex flex-col p-0">
<HeaderContent title={title} subtitle={subtitle} />
</ModalHeader>
<ModalBody className="px-0 py-[20px]">{children}</ModalBody>
{actions && actions.length > 0 && (
<ModalFooter className="flex-col flex justify-start p-0">
<FooterContent actions={actions} closeModal={closeModal} />
</ModalFooter>
)}
</>
)}
</ModalContent>
</Modal>
);
}
BaseModal.defaultProps = {
subtitle: undefined,
actions: [],
children: null,
};
export default BaseModal;

View File

@ -0,0 +1,34 @@
import { Button } from "@nextui-org/react";
import React from "react";
export interface Action {
action: () => void;
label: string;
className?: string;
closeAfterAction?: boolean;
}
interface FooterContentProps {
actions: Action[];
closeModal: () => void;
}
export function FooterContent({ actions, closeModal }: FooterContentProps) {
return (
<>
{actions.map(({ action, label, className, closeAfterAction }) => (
<Button
key={label}
type="button"
onClick={() => {
action();
if (closeAfterAction) closeModal();
}}
className={className}
>
{label}
</Button>
))}
</>
);
}

View File

@ -0,0 +1,21 @@
import React from "react";
interface HeaderContentProps {
title: string;
subtitle?: string;
}
export function HeaderContent({ title, subtitle }: HeaderContentProps) {
return (
<>
<h3>{title}</h3>
{subtitle && (
<span className="text-neutral-400 text-sm font-light">{subtitle}</span>
)}
</>
);
}
HeaderContent.defaultProps = {
subtitle: undefined,
};

View File

@ -0,0 +1,64 @@
import { render, screen, act } from "@testing-library/react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { AutocompleteCombobox } from "./AutocompleteCombobox";
const onChangeMock = vi.fn();
const renderComponent = () =>
render(
<AutocompleteCombobox
ariaLabel="model"
items={[
{ value: "m1", label: "model1" },
{ value: "m2", label: "model2" },
{ value: "m3", label: "model3" },
]}
defaultKey="m1"
onChange={onChangeMock}
/>,
);
describe("AutocompleteCombobox", () => {
it("should render a combobox with the default value", () => {
renderComponent();
const modelInput = screen.getByRole("combobox", { name: "model" });
expect(modelInput).toHaveValue("model1");
});
it("should open a dropdown with the available values", () => {
renderComponent();
const modelInput = screen.getByRole("combobox", { name: "model" });
expect(screen.queryByText("model2")).not.toBeInTheDocument();
expect(screen.queryByText("model3")).not.toBeInTheDocument();
act(() => {
userEvent.click(modelInput);
});
expect(screen.getByText("model2")).toBeInTheDocument();
expect(screen.getByText("model3")).toBeInTheDocument();
});
it("should call the onChange handler when a new value is selected", () => {
renderComponent();
const modelInput = screen.getByRole("combobox", { name: "model" });
expect(modelInput).toHaveValue("model1");
act(() => {
userEvent.click(modelInput);
});
const model2 = screen.getByText("model2");
act(() => {
userEvent.click(model2);
});
expect(modelInput).toHaveValue("model2");
expect(onChangeMock).toHaveBeenCalledWith("model2");
});
});

View File

@ -0,0 +1,63 @@
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "../../i18n/declaration";
type Label = "model" | "agent" | "language";
const LABELS: Record<Label, I18nKey> = {
model: I18nKey.CONFIGURATION$MODEL_SELECT_LABEL,
agent: I18nKey.CONFIGURATION$AGENT_SELECT_LABEL,
language: I18nKey.CONFIGURATION$LANGUAGE_SELECT_LABEL,
};
const PLACEHOLDERS: Record<Label, I18nKey> = {
model: I18nKey.CONFIGURATION$MODEL_SELECT_PLACEHOLDER,
agent: I18nKey.CONFIGURATION$AGENT_SELECT_PLACEHOLDER,
language: I18nKey.CONFIGURATION$LANGUAGE_SELECT_PLACEHOLDER,
};
type AutocompleteItemType = {
value: string;
label: string;
};
interface AutocompleteComboboxProps {
ariaLabel: Label;
items: AutocompleteItemType[];
defaultKey: string;
onChange: (key: string) => void;
allowCustomValue?: boolean;
}
export function AutocompleteCombobox({
ariaLabel,
items,
defaultKey,
onChange,
allowCustomValue = false,
}: AutocompleteComboboxProps) {
const { t } = useTranslation();
return (
<Autocomplete
aria-label={ariaLabel}
label={t(LABELS[ariaLabel])}
placeholder={t(PLACEHOLDERS[ariaLabel])}
defaultItems={items}
defaultSelectedKey={defaultKey}
allowsCustomValue={allowCustomValue}
onInputChange={(value) => {
onChange(value);
}}
>
{(item) => (
<AutocompleteItem key={item.value}>{item.label}</AutocompleteItem>
)}
</Autocomplete>
);
}
AutocompleteCombobox.defaultProps = {
allowCustomValue: false,
};

View File

@ -0,0 +1,101 @@
import React from "react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import SettingsForm from "./SettingsForm";
const onModelChangeMock = vi.fn();
const onAgentChangeMock = vi.fn();
const onLanguageChangeMock = vi.fn();
const renderSettingsForm = (settings: Partial<Settings>) => {
render(
<SettingsForm
settings={settings}
models={["model1", "model2", "model3"]}
agents={["agent1", "agent2", "agent3"]}
onModelChange={onModelChangeMock}
onAgentChange={onAgentChangeMock}
onLanguageChange={onLanguageChangeMock}
/>,
);
};
describe("SettingsForm", () => {
it("should display the first values in the array by default", () => {
renderSettingsForm({});
const modelInput = screen.getByRole("combobox", { name: "model" });
const agentInput = screen.getByRole("combobox", { name: "agent" });
const languageInput = screen.getByRole("combobox", { name: "language" });
expect(modelInput).toHaveValue("model1");
expect(agentInput).toHaveValue("agent1");
expect(languageInput).toHaveValue("English");
});
it("should display the existing values if it they are present", () => {
renderSettingsForm({
LLM_MODEL: "model2",
AGENT: "agent2",
LANGUAGE: "es",
});
const modelInput = screen.getByRole("combobox", { name: "model" });
const agentInput = screen.getByRole("combobox", { name: "agent" });
const languageInput = screen.getByRole("combobox", { name: "language" });
expect(modelInput).toHaveValue("model2");
expect(agentInput).toHaveValue("agent2");
expect(languageInput).toHaveValue("Español");
});
describe("onChange handlers", () => {
it("should call the onModelChange handler when the model changes", () => {
renderSettingsForm({});
const modelInput = screen.getByRole("combobox", { name: "model" });
act(() => {
userEvent.click(modelInput);
});
const model3 = screen.getByText("model3");
act(() => {
userEvent.click(model3);
});
expect(onModelChangeMock).toHaveBeenCalledWith("model3");
});
it("should call the onAgentChange handler when the agent changes", () => {
renderSettingsForm({});
const agentInput = screen.getByRole("combobox", { name: "agent" });
act(() => {
userEvent.click(agentInput);
});
const agent3 = screen.getByText("agent3");
act(() => {
userEvent.click(agent3);
});
expect(onAgentChangeMock).toHaveBeenCalledWith("agent3");
});
it("should call the onLanguageChange handler when the language changes", () => {
renderSettingsForm({});
const languageInput = screen.getByRole("combobox", { name: "language" });
act(() => {
userEvent.click(languageInput);
});
const french = screen.getByText("Français");
act(() => {
userEvent.click(french);
});
expect(onLanguageChangeMock).toHaveBeenCalledWith("Français");
});
});
});

View File

@ -0,0 +1,48 @@
import React from "react";
import { AvailableLanguages } from "../../i18n";
import { AutocompleteCombobox } from "./AutocompleteCombobox";
interface SettingsFormProps {
settings: Partial<Settings>;
models: string[];
agents: string[];
onModelChange: (model: string) => void;
onAgentChange: (agent: string) => void;
onLanguageChange: (language: string) => void;
}
function SettingsForm({
settings,
models,
agents,
onModelChange,
onAgentChange,
onLanguageChange,
}: SettingsFormProps) {
return (
<>
<AutocompleteCombobox
ariaLabel="model"
items={models.map((model) => ({ value: model, label: model }))}
defaultKey={settings.LLM_MODEL || models[0]}
onChange={onModelChange}
allowCustomValue // user can type in a custom LLM model that is not in the list
/>
<AutocompleteCombobox
ariaLabel="agent"
items={agents.map((agent) => ({ value: agent, label: agent }))}
defaultKey={settings.AGENT || agents[0]}
onChange={onAgentChange}
/>
<AutocompleteCombobox
ariaLabel="language"
items={AvailableLanguages}
defaultKey={settings.LANGUAGE || "en"}
onChange={onLanguageChange}
/>
</>
);
}
export default SettingsForm;

View File

@ -0,0 +1,136 @@
import { waitFor, screen, act, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { Mock } from "vitest";
import {
fetchModels,
fetchAgents,
saveSettings,
getCurrentSettings,
} from "../../services/settingsService";
import SettingsModal from "./SettingsModal";
vi.mock("../../services/settingsService", async (importOriginal) => ({
...(await importOriginal<typeof import("../../services/settingsService")>()),
getCurrentSettings: vi.fn().mockReturnValue({}),
saveSettings: vi.fn(),
fetchModels: vi
.fn()
.mockResolvedValue(Promise.resolve(["model1", "model2", "model3"])),
fetchAgents: vi
.fn()
.mockResolvedValue(Promise.resolve(["agent1", "agent2", "agent3"])),
}));
describe("SettingsModal", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should fetch existing agents and models from the API", async () => {
render(<SettingsModal isOpen onOpenChange={vi.fn()} />);
await waitFor(() => {
expect(fetchModels).toHaveBeenCalledTimes(1);
expect(fetchAgents).toHaveBeenCalledTimes(1);
});
});
it.todo(
"should display a loading spinner when fetching the models and agents",
);
it("should close the modal when the cancel button is clicked", async () => {
const onOpenChange = vi.fn();
await act(async () =>
render(<SettingsModal isOpen onOpenChange={onOpenChange} />),
);
const cancelButton = screen.getByRole("button", {
name: /MODAL_CLOSE_BUTTON_LABEL/i, // i18n key
});
act(() => {
userEvent.click(cancelButton);
});
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("should call saveSettings (and close) with the new values", async () => {
const onOpenChangeMock = vi.fn();
await act(async () =>
render(<SettingsModal isOpen onOpenChange={onOpenChangeMock} />),
);
const saveButton = screen.getByRole("button", { name: /save/i });
const modelInput = screen.getByRole("combobox", { name: "model" });
act(() => {
userEvent.click(modelInput);
});
const model3 = screen.getByText("model3");
act(() => {
userEvent.click(model3);
});
act(() => {
userEvent.click(saveButton);
});
expect(saveSettings).toHaveBeenCalledWith({
LLM_MODEL: "model3",
});
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
// This test does not seem to rerender the component correctly
// Therefore, we cannot test the reset of the state
it.skip("should reset state when the cancel button is clicked", async () => {
(getCurrentSettings as Mock).mockReturnValue({
LLM_MODEL: "model1",
AGENT: "agent1",
LANGUAGE: "English",
});
const onOpenChange = vi.fn();
const { rerender } = render(
<SettingsModal isOpen onOpenChange={onOpenChange} />,
);
await waitFor(() => {
expect(screen.getByRole("combobox", { name: "model" })).toHaveValue(
"model1",
);
});
const cancelButton = screen.getByRole("button", { name: /cancel/i });
const modelInput = screen.getByRole("combobox", { name: "model" });
act(() => {
userEvent.click(modelInput);
});
const model3 = screen.getByText("model3");
act(() => {
userEvent.click(model3);
});
expect(modelInput).toHaveValue("model3");
act(() => {
userEvent.click(cancelButton);
});
rerender(<SettingsModal isOpen onOpenChange={onOpenChange} />);
await waitFor(() => {
expect(screen.getByRole("combobox", { name: "model" })).toHaveValue(
"model1",
);
});
});
});

View File

@ -0,0 +1,100 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@nextui-org/react";
import BaseModal from "../base-modal/BaseModal";
import SettingsForm from "./SettingsForm";
import {
fetchAgents,
fetchModels,
getCurrentSettings,
saveSettings,
} from "../../services/settingsService";
import { I18nKey } from "../../i18n/declaration";
import { AvailableLanguages } from "../../i18n";
interface SettingsProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
const { t } = useTranslation();
const currentSettings = React.useMemo(() => getCurrentSettings(), []);
const [models, setModels] = React.useState<string[]>([]);
const [agents, setAgents] = React.useState<string[]>([]);
const [settings, setSettings] =
React.useState<Partial<Settings>>(currentSettings);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
(async () => {
try {
setModels(await fetchModels());
setAgents(await fetchAgents());
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
})();
}, []);
const handleModelChange = (model: string) => {
setSettings((prev) => ({ ...prev, LLM_MODEL: model }));
};
const handleAgentChange = (agent: string) => {
setSettings((prev) => ({ ...prev, AGENT: agent }));
};
const handleLanguageChange = (language: string) => {
const key = AvailableLanguages.find(
(lang) => lang.label === language,
)?.value;
if (key) setSettings((prev) => ({ ...prev, LANGUAGE: key }));
};
return (
<BaseModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title={t(I18nKey.CONFIGURATION$MODAL_TITLE)}
subtitle={t(I18nKey.CONFIGURATION$MODAL_SUB_TITLE)}
actions={[
{
label: t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL),
action: () => {
saveSettings(settings);
},
closeAfterAction: true,
className: "bg-primary rounded-small",
},
{
label: t(I18nKey.CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL),
action: () => {
setSettings(currentSettings); // reset settings from any changes
},
closeAfterAction: true,
className: "bg-neutral-500 rounded-small",
},
]}
>
{loading && <Spinner />}
{!loading && (
<SettingsForm
settings={settings}
models={models}
agents={agents}
onModelChange={handleModelChange}
onAgentChange={handleAgentChange}
onLanguageChange={handleLanguageChange}
/>
)}
</BaseModal>
);
}
export default SettingsModal;

View File

@ -188,6 +188,20 @@
"pt": "Idioma",
"es": "Idioma"
},
"CONFIGURATION$LANGUAGE_SELECT_PLACEHOLDER": {
"en": "Select a language",
"zh-CN": "选择一种语言",
"de": "Wähle eine Sprache",
"ko-KR": "언어 선택",
"no": "Velg et språk",
"zh-TW": "選擇一種語言",
"it": "Seleziona una lingua",
"pt": "Selecione um idioma",
"es": "Seleccionar un idioma",
"ar": "حدد لغة",
"fr": "Sélectionner une langue",
"tr": "Dil Seç"
},
"CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL": {
"en": "Close",
"zh-CN": "关闭",

5
frontend/src/services/settings.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
type Settings = {
LLM_MODEL: string;
AGENT: string;
LANGUAGE: string;
};