mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor(frontend): settings and friends (#1400)
* refactor settings and friends * extend base modal to support disabled property * extend settings handler to change language via i18next * remove unused settings.d.ts --------- Co-authored-by: Jim Su <jimsu@protonmail.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
This commit is contained in:
parent
24b71927c3
commit
8954855ba3
@ -9,13 +9,14 @@ import Workspace from "#/components/Workspace";
|
||||
import LoadPreviousSessionModal from "#/components/modals/load-previous-session/LoadPreviousSessionModal";
|
||||
import SettingsModal from "#/components/modals/settings/SettingsModal";
|
||||
import { fetchMsgTotal } from "#/services/session";
|
||||
import { initializeAgent } from "#/services/settingsService";
|
||||
import Socket from "#/services/socket";
|
||||
import { ResFetchMsgTotal } from "#/types/ResponseType";
|
||||
import "./App.css";
|
||||
import AgentControlBar from "./components/AgentControlBar";
|
||||
import AgentStatusBar from "./components/AgentStatusBar";
|
||||
import Terminal from "./components/terminal/Terminal";
|
||||
import { initializeAgent } from "./services/agent";
|
||||
import { getSettings } from "./services/settings";
|
||||
|
||||
interface Props {
|
||||
setSettingOpen: (isOpen: boolean) => void;
|
||||
@ -72,7 +73,7 @@ function App(): JSX.Element {
|
||||
if (initOnce) return;
|
||||
initOnce = true;
|
||||
|
||||
initializeAgent();
|
||||
initializeAgent(getSettings());
|
||||
|
||||
Socket.registerCallback("open", [getMsgTotal]);
|
||||
|
||||
|
||||
9
frontend/src/api/index.ts
Normal file
9
frontend/src/api/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export async function fetchModels() {
|
||||
const response = await fetch(`/api/litellm-models`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchAgents() {
|
||||
const response = await fetch(`/api/agents`);
|
||||
return response.json();
|
||||
}
|
||||
@ -96,6 +96,42 @@ describe("BaseModal", () => {
|
||||
expect(screen.getByText("Children")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable the action given the condition", () => {
|
||||
const { rerender } = render(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
title="Settings"
|
||||
actions={[
|
||||
{
|
||||
label: "Save",
|
||||
action: () => {},
|
||||
isDisabled: true,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
|
||||
rerender(
|
||||
<BaseModal
|
||||
isOpen
|
||||
onOpenChange={vi.fn}
|
||||
title="Settings"
|
||||
actions={[
|
||||
{
|
||||
label: "Save",
|
||||
action: () => {},
|
||||
isDisabled: false,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Save")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it.skip("should not close if the backdrop or escape key is pressed", () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(
|
||||
|
||||
@ -3,6 +3,7 @@ import React from "react";
|
||||
|
||||
export interface Action {
|
||||
action: () => void;
|
||||
isDisabled?: boolean;
|
||||
label: string;
|
||||
className?: string;
|
||||
closeAfterAction?: boolean;
|
||||
@ -16,19 +17,22 @@ interface FooterContentProps {
|
||||
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>
|
||||
))}
|
||||
{actions.map(
|
||||
({ action, isDisabled, label, className, closeAfterAction }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
isDisabled={isDisabled}
|
||||
onClick={() => {
|
||||
action();
|
||||
if (closeAfterAction) closeModal();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,15 +4,22 @@ import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import AgentTaskState from "#/types/AgentTaskState";
|
||||
import SettingsForm from "./SettingsForm";
|
||||
import { Settings } from "#/services/settings";
|
||||
|
||||
const onModelChangeMock = vi.fn();
|
||||
const onAgentChangeMock = vi.fn();
|
||||
const onLanguageChangeMock = vi.fn();
|
||||
|
||||
const renderSettingsForm = (settings: Partial<Settings>) => {
|
||||
const renderSettingsForm = (settings?: Settings) => {
|
||||
renderWithProviders(
|
||||
<SettingsForm
|
||||
settings={settings}
|
||||
settings={
|
||||
settings || {
|
||||
LLM_MODEL: "model1",
|
||||
AGENT: "agent1",
|
||||
LANGUAGE: "en",
|
||||
}
|
||||
}
|
||||
models={["model1", "model2", "model3"]}
|
||||
agents={["agent1", "agent2", "agent3"]}
|
||||
onModelChange={onModelChangeMock}
|
||||
@ -24,7 +31,7 @@ const renderSettingsForm = (settings: Partial<Settings>) => {
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
it("should display the first values in the array by default", () => {
|
||||
renderSettingsForm({});
|
||||
renderSettingsForm();
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
const agentInput = screen.getByRole("combobox", { name: "agent" });
|
||||
@ -54,7 +61,11 @@ describe("SettingsForm", () => {
|
||||
it("should disable settings while task is running", () => {
|
||||
renderWithProviders(
|
||||
<SettingsForm
|
||||
settings={{}}
|
||||
settings={{
|
||||
LLM_MODEL: "model1",
|
||||
AGENT: "agent1",
|
||||
LANGUAGE: "en",
|
||||
}}
|
||||
models={["model1", "model2", "model3"]}
|
||||
agents={["agent1", "agent2", "agent3"]}
|
||||
onModelChange={onModelChangeMock}
|
||||
@ -74,7 +85,7 @@ describe("SettingsForm", () => {
|
||||
|
||||
describe("onChange handlers", () => {
|
||||
it("should call the onModelChange handler when the model changes", () => {
|
||||
renderSettingsForm({});
|
||||
renderSettingsForm();
|
||||
|
||||
const modelInput = screen.getByRole("combobox", { name: "model" });
|
||||
act(() => {
|
||||
@ -90,7 +101,7 @@ describe("SettingsForm", () => {
|
||||
});
|
||||
|
||||
it("should call the onAgentChange handler when the agent changes", () => {
|
||||
renderSettingsForm({});
|
||||
renderSettingsForm();
|
||||
|
||||
const agentInput = screen.getByRole("combobox", { name: "agent" });
|
||||
act(() => {
|
||||
@ -106,7 +117,7 @@ describe("SettingsForm", () => {
|
||||
});
|
||||
|
||||
it("should call the onLanguageChange handler when the language changes", () => {
|
||||
renderSettingsForm({});
|
||||
renderSettingsForm();
|
||||
|
||||
const languageInput = screen.getByRole("combobox", { name: "language" });
|
||||
act(() => {
|
||||
|
||||
@ -6,9 +6,10 @@ import { I18nKey } from "../../../i18n/declaration";
|
||||
import { RootState } from "../../../store";
|
||||
import AgentTaskState from "../../../types/AgentTaskState";
|
||||
import { AutocompleteCombobox } from "./AutocompleteCombobox";
|
||||
import { Settings } from "#/services/settings";
|
||||
|
||||
interface SettingsFormProps {
|
||||
settings: Partial<Settings>;
|
||||
settings: Settings;
|
||||
models: string[];
|
||||
agents: string[];
|
||||
|
||||
|
||||
@ -3,18 +3,32 @@ import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { Mock } from "vitest";
|
||||
import {
|
||||
fetchAgents,
|
||||
fetchModels,
|
||||
getCurrentSettings,
|
||||
saveSettings,
|
||||
} from "#/services/settingsService";
|
||||
import i18next from "i18next";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
import { Settings, getSettings, saveSettings } from "#/services/settings";
|
||||
import { initializeAgent } from "#/services/agent";
|
||||
import toast from "#/utils/toast";
|
||||
import { fetchAgents, fetchModels } from "#/api";
|
||||
|
||||
vi.mock("#/services/settingsService", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("#/services/settingsService")>()),
|
||||
getCurrentSettings: vi.fn().mockReturnValue({}),
|
||||
const toastSpy = vi.spyOn(toast, "settingsChanged");
|
||||
const i18nSpy = vi.spyOn(i18next, "changeLanguage");
|
||||
|
||||
vi.mock("#/services/settings", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("#/services/settings")>()),
|
||||
getSettings: vi.fn().mockReturnValue({
|
||||
LLM_MODEL: "gpt-3.5-turbo",
|
||||
AGENT: "MonologueAgent",
|
||||
LANGUAGE: "en",
|
||||
}),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/services/agent", async () => ({
|
||||
initializeAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("#/api")>()),
|
||||
fetchModels: vi
|
||||
.fn()
|
||||
.mockResolvedValue(Promise.resolve(["model1", "model2", "model3"])),
|
||||
@ -37,10 +51,6 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () =>
|
||||
@ -58,82 +68,188 @@ describe("SettingsModal", () => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should call saveSettings (and close) with the new values", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
it("should disable the save button if the settings are the same as the initial settings", async () => {
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
<SettingsModal isOpen onOpenChange={onOpenChangeMock} />,
|
||||
),
|
||||
renderWithProviders(<SettingsModal isOpen onOpenChange={vi.fn()} />),
|
||||
);
|
||||
|
||||
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);
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
// 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",
|
||||
it("should disabled the save button if the settings contain a missing value", () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
(getSettings as Mock).mockReturnValueOnce({
|
||||
LLM_MODEL: "gpt-3.5-turbo",
|
||||
AGENT: "",
|
||||
});
|
||||
|
||||
const onOpenChange = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<SettingsModal isOpen onOpenChange={onOpenChange} />,
|
||||
renderWithProviders(
|
||||
<SettingsModal isOpen onOpenChange={onOpenChangeMock} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "model" })).toHaveValue(
|
||||
"model1",
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("onHandleSave", () => {
|
||||
const initialSettings: Settings = {
|
||||
LLM_MODEL: "gpt-3.5-turbo",
|
||||
AGENT: "MonologueAgent",
|
||||
LANGUAGE: "en",
|
||||
};
|
||||
|
||||
it("should save the settings", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
<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({
|
||||
...initialSettings,
|
||||
LLM_MODEL: "model3",
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
it("should reinitialize agent", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
<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(initializeAgent).toHaveBeenCalledWith({
|
||||
...initialSettings,
|
||||
LLM_MODEL: "model3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should display a toast for every change", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
<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(toastSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should change the language", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
<SettingsModal isOpen onOpenChange={onOpenChangeMock} />,
|
||||
),
|
||||
);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
const languageInput = screen.getByRole("combobox", { name: "language" });
|
||||
|
||||
act(() => {
|
||||
userEvent.click(languageInput);
|
||||
});
|
||||
|
||||
const spanish = screen.getByText("Español");
|
||||
|
||||
act(() => {
|
||||
userEvent.click(spanish);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
|
||||
expect(i18nSpy).toHaveBeenCalledWith("es");
|
||||
});
|
||||
|
||||
it("should close the modal", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
await act(async () =>
|
||||
renderWithProviders(
|
||||
<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(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should reset setting changes when the cancel button is clicked");
|
||||
|
||||
it.todo(
|
||||
"should display a loading spinner when fetching the models and agents",
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
fetchAgents,
|
||||
fetchModels,
|
||||
getCurrentSettings,
|
||||
saveSettings,
|
||||
} from "#/services/settingsService";
|
||||
import { Spinner } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18next from "i18next";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { fetchAgents, fetchModels } from "#/api";
|
||||
import BaseModal from "../base-modal/BaseModal";
|
||||
import SettingsForm from "./SettingsForm";
|
||||
import {
|
||||
Settings,
|
||||
saveSettings,
|
||||
getSettings,
|
||||
getSettingsDifference,
|
||||
} from "#/services/settings";
|
||||
import toast from "#/utils/toast";
|
||||
import { initializeAgent } from "#/services/agent";
|
||||
|
||||
interface SettingsProps {
|
||||
isOpen: boolean;
|
||||
@ -19,12 +23,11 @@ interface SettingsProps {
|
||||
|
||||
function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentSettings = React.useMemo(() => getCurrentSettings(), []);
|
||||
const currentSettings = getSettings();
|
||||
|
||||
const [models, setModels] = React.useState<string[]>([]);
|
||||
const [agents, setAgents] = React.useState<string[]>([]);
|
||||
const [settings, setSettings] =
|
||||
React.useState<Partial<Settings>>(currentSettings);
|
||||
const [settings, setSettings] = React.useState<Settings>(currentSettings);
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
@ -57,6 +60,17 @@ function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
if (key) setSettings((prev) => ({ ...prev, LANGUAGE: key }));
|
||||
};
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
const updatedSettings = getSettingsDifference(settings);
|
||||
saveSettings(settings);
|
||||
i18next.changeLanguage(settings.LANGUAGE);
|
||||
initializeAgent(settings); // reinitialize the agent with the new settings
|
||||
|
||||
Object.entries(updatedSettings).forEach(([key, value]) => {
|
||||
toast.settingsChanged(`${key} set to "${value}"`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
@ -66,9 +80,10 @@ function SettingsModal({ isOpen, onOpenChange }: SettingsProps) {
|
||||
actions={[
|
||||
{
|
||||
label: t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL),
|
||||
action: () => {
|
||||
saveSettings(settings);
|
||||
},
|
||||
action: handleSaveSettings,
|
||||
isDisabled:
|
||||
Object.values(settings).some((value) => !value) ||
|
||||
JSON.stringify(settings) === JSON.stringify(currentSettings),
|
||||
closeAfterAction: true,
|
||||
className: "bg-primary rounded-lg",
|
||||
},
|
||||
|
||||
31
frontend/src/services/agent.test.ts
Normal file
31
frontend/src/services/agent.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import ActionType from "#/types/ActionType";
|
||||
import { Settings } from "./settings";
|
||||
import { initializeAgent } from "./agent";
|
||||
import Socket from "./socket";
|
||||
import store from "#/store";
|
||||
import { setInitialized } from "#/state/taskSlice";
|
||||
|
||||
const sendSpy = vi.spyOn(Socket, "send");
|
||||
const dispatchSpy = vi.spyOn(store, "dispatch");
|
||||
|
||||
describe("initializeAgent", () => {
|
||||
it("Should initialize the agent with the current settings", () => {
|
||||
const settings: Settings = {
|
||||
LLM_MODEL: "llm_value",
|
||||
AGENT: "agent_value",
|
||||
LANGUAGE: "language_value",
|
||||
};
|
||||
|
||||
const event = {
|
||||
action: ActionType.INIT,
|
||||
args: settings,
|
||||
};
|
||||
|
||||
initializeAgent(settings);
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(JSON.stringify(event));
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(setInitialized(false));
|
||||
});
|
||||
});
|
||||
17
frontend/src/services/agent.ts
Normal file
17
frontend/src/services/agent.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { setInitialized } from "#/state/taskSlice";
|
||||
import store from "#/store";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import { Settings } from "./settings";
|
||||
import Socket from "./socket";
|
||||
|
||||
/**
|
||||
* Initialize the agent with the current settings.
|
||||
* @param settings - The new settings.
|
||||
*/
|
||||
export const initializeAgent = (settings: Settings) => {
|
||||
const event = { action: ActionType.INIT, args: settings };
|
||||
const eventString = JSON.stringify(event);
|
||||
|
||||
store.dispatch(setInitialized(false));
|
||||
Socket.send(eventString);
|
||||
};
|
||||
5
frontend/src/services/settings.d.ts
vendored
5
frontend/src/services/settings.d.ts
vendored
@ -1,5 +0,0 @@
|
||||
type Settings = {
|
||||
LLM_MODEL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
};
|
||||
137
frontend/src/services/settings.test.ts
Normal file
137
frontend/src/services/settings.test.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi, Mock } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
getSettings,
|
||||
getSettingsDifference,
|
||||
saveSettings,
|
||||
} from "./settings";
|
||||
|
||||
Storage.prototype.getItem = vi.fn();
|
||||
Storage.prototype.setItem = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("should get the stored settings", () => {
|
||||
(localStorage.getItem as Mock)
|
||||
.mockReturnValueOnce("llm_value")
|
||||
.mockReturnValueOnce("agent_value")
|
||||
.mockReturnValueOnce("language_value");
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
expect(settings).toEqual({
|
||||
LLM_MODEL: "llm_value",
|
||||
AGENT: "agent_value",
|
||||
LANGUAGE: "language_value",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle return defaults if localStorage key does not exist", () => {
|
||||
(localStorage.getItem as Mock)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(null);
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
expect(settings).toEqual({
|
||||
LLM_MODEL: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
AGENT: DEFAULT_SETTINGS.AGENT,
|
||||
LANGUAGE: DEFAULT_SETTINGS.LANGUAGE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSettings", () => {
|
||||
it("should save the settings", () => {
|
||||
const settings = {
|
||||
LLM_MODEL: "llm_value",
|
||||
AGENT: "agent_value",
|
||||
LANGUAGE: "language_value",
|
||||
};
|
||||
|
||||
saveSettings(settings);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith("LLM_MODEL", "llm_value");
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith("AGENT", "agent_value");
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"LANGUAGE",
|
||||
"language_value",
|
||||
);
|
||||
});
|
||||
|
||||
it("should save partial settings", () => {
|
||||
const settings = {
|
||||
LLM_MODEL: "llm_value",
|
||||
};
|
||||
|
||||
saveSettings(settings);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledOnce();
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith("LLM_MODEL", "llm_value");
|
||||
});
|
||||
|
||||
it("should not save invalid settings", () => {
|
||||
const settings = {
|
||||
LLM_MODEL: "llm_value",
|
||||
AGENT: "agent_value",
|
||||
LANGUAGE: "language_value",
|
||||
INVALID: "invalid_value",
|
||||
};
|
||||
|
||||
saveSettings(settings);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith("LLM_MODEL", "llm_value");
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith("AGENT", "agent_value");
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"LANGUAGE",
|
||||
"language_value",
|
||||
);
|
||||
expect(localStorage.setItem).not.toHaveBeenCalledWith(
|
||||
"INVALID",
|
||||
"invalid_value",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingsDifference", () => {
|
||||
beforeEach(() => {
|
||||
(localStorage.getItem as Mock)
|
||||
.mockReturnValueOnce("llm_value")
|
||||
.mockReturnValueOnce("agent_value")
|
||||
.mockReturnValueOnce("language_value");
|
||||
});
|
||||
|
||||
it("should return updated settings", () => {
|
||||
const settings = {
|
||||
LLM_MODEL: "new_llm_value",
|
||||
AGENT: "new_agent_value",
|
||||
LANGUAGE: "language_value",
|
||||
};
|
||||
|
||||
const updatedSettings = getSettingsDifference(settings);
|
||||
|
||||
expect(updatedSettings).toEqual({
|
||||
LLM_MODEL: "new_llm_value",
|
||||
AGENT: "new_agent_value",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not handle invalid settings", () => {
|
||||
const settings = {
|
||||
LLM_MODEL: "new_llm_value",
|
||||
AGENT: "new_agent_value",
|
||||
INVALID: "invalid_value",
|
||||
};
|
||||
|
||||
const updatedSettings = getSettingsDifference(settings);
|
||||
|
||||
expect(updatedSettings).toEqual({
|
||||
LLM_MODEL: "new_llm_value",
|
||||
AGENT: "new_agent_value",
|
||||
});
|
||||
});
|
||||
});
|
||||
63
frontend/src/services/settings.ts
Normal file
63
frontend/src/services/settings.ts
Normal file
@ -0,0 +1,63 @@
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
AGENT: string;
|
||||
LANGUAGE: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_MODEL: "gpt-3.5-turbo",
|
||||
AGENT: "MonologueAgent",
|
||||
LANGUAGE: "en",
|
||||
};
|
||||
|
||||
const validKeys = Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[];
|
||||
|
||||
/**
|
||||
* Get the settings from local storage or use the default settings if not found
|
||||
*/
|
||||
export const getSettings = (): Settings => ({
|
||||
LLM_MODEL: localStorage.getItem("LLM_MODEL") || DEFAULT_SETTINGS.LLM_MODEL,
|
||||
AGENT: localStorage.getItem("AGENT") || DEFAULT_SETTINGS.AGENT,
|
||||
LANGUAGE: localStorage.getItem("LANGUAGE") || DEFAULT_SETTINGS.LANGUAGE,
|
||||
});
|
||||
|
||||
/**
|
||||
* Save the settings to local storage. Only valid settings are saved.
|
||||
* @param settings - the settings to save
|
||||
*/
|
||||
export const saveSettings = (settings: Partial<Settings>) => {
|
||||
Object.keys(settings).forEach((key) => {
|
||||
const isValid = validKeys.includes(key as keyof Settings);
|
||||
const value = settings[key as keyof Settings];
|
||||
|
||||
if (isValid && value) localStorage.setItem(key, value);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the difference between the current settings and the provided settings.
|
||||
* Useful for notifiying the user of exact changes.
|
||||
*
|
||||
* @example
|
||||
* // Assuming the current settings are: { LLM_MODEL: "gpt-3.5", AGENT: "MonologueAgent", LANGUAGE: "en" }
|
||||
* const updatedSettings = getSettingsDifference({ LLM_MODEL: "gpt-3.5", AGENT: "OTHER_AGENT", LANGUAGE: "en" });
|
||||
* // updatedSettings = { AGENT: "OTHER_AGENT" }
|
||||
*
|
||||
* @param settings - the settings to compare
|
||||
* @returns the updated settings
|
||||
*/
|
||||
export const getSettingsDifference = (settings: Partial<Settings>) => {
|
||||
const currentSettings = getSettings();
|
||||
const updatedSettings: Partial<Settings> = {};
|
||||
|
||||
Object.keys(settings).forEach((key) => {
|
||||
if (
|
||||
validKeys.includes(key as keyof Settings) &&
|
||||
settings[key as keyof Settings] !== currentSettings[key as keyof Settings]
|
||||
) {
|
||||
updatedSettings[key as keyof Settings] = settings[key as keyof Settings];
|
||||
}
|
||||
});
|
||||
|
||||
return updatedSettings;
|
||||
};
|
||||
@ -1,101 +0,0 @@
|
||||
import { ArgConfigType } from "#/types/ConfigType";
|
||||
import { getSettingOrDefault, getUpdatedSettings } from "./settingsService";
|
||||
|
||||
Storage.prototype.getItem = vi.fn();
|
||||
|
||||
describe("getSettingOrDefault", () => {
|
||||
it("should return the value from localStorage if it exists", () => {
|
||||
(localStorage.getItem as jest.Mock).mockReturnValue("value");
|
||||
const result = getSettingOrDefault("some_key");
|
||||
|
||||
expect(result).toEqual("value");
|
||||
});
|
||||
|
||||
it("should return the default value if localStorage does not exist", () => {
|
||||
(localStorage.getItem as jest.Mock).mockReturnValue(null);
|
||||
const result = getSettingOrDefault("LLM_MODEL");
|
||||
|
||||
expect(result).toEqual("gpt-3.5-turbo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUpdatedSettings", () => {
|
||||
it("should return initial settings if newSettings is empty", () => {
|
||||
const oldSettings = { key1: "value1" };
|
||||
|
||||
const result = getUpdatedSettings({}, oldSettings);
|
||||
|
||||
expect(result).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("should update settings", () => {
|
||||
const oldSettings = {
|
||||
[ArgConfigType.LLM_MODEL]: "gpt-4-0125-preview",
|
||||
[ArgConfigType.AGENT]: "MonologueAgent",
|
||||
[ArgConfigType.LANGUAGE]: "en",
|
||||
};
|
||||
const newSettings = {
|
||||
[ArgConfigType.AGENT]: "OtherAgent",
|
||||
};
|
||||
|
||||
const result = getUpdatedSettings(newSettings, oldSettings);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
[ArgConfigType.AGENT]: "OtherAgent",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show no values if they are equal", () => {
|
||||
const oldSettings = {
|
||||
[ArgConfigType.LLM_MODEL]: "gpt-4-0125-preview",
|
||||
[ArgConfigType.AGENT]: "MonologueAgent",
|
||||
};
|
||||
const newSettings = {
|
||||
[ArgConfigType.AGENT]: "MonologueAgent",
|
||||
};
|
||||
|
||||
const result = getUpdatedSettings(newSettings, oldSettings);
|
||||
|
||||
expect(result).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("should update all settings", () => {
|
||||
const oldSettings = {
|
||||
[ArgConfigType.LLM_MODEL]: "gpt-4-0125-preview",
|
||||
[ArgConfigType.AGENT]: "MonologueAgent",
|
||||
key1: "value1",
|
||||
};
|
||||
const newSettings = {
|
||||
[ArgConfigType.AGENT]: "CodeActAgent",
|
||||
[ArgConfigType.LANGUAGE]: "es",
|
||||
key1: "newvalue1",
|
||||
key2: "value2",
|
||||
};
|
||||
|
||||
const result = getUpdatedSettings(newSettings, oldSettings);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
[ArgConfigType.AGENT]: "CodeActAgent",
|
||||
[ArgConfigType.LANGUAGE]: "es",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update settings that are not supported", () => {
|
||||
const oldSettings = {
|
||||
[ArgConfigType.LLM_MODEL]: "gpt-4-0125-preview",
|
||||
[ArgConfigType.AGENT]: "MonologueAgent",
|
||||
};
|
||||
const newSettings = {
|
||||
[ArgConfigType.LLM_MODEL]: "gpt-4-0125-preview",
|
||||
[ArgConfigType.AGENT]: "CodeActAgent",
|
||||
key1: "newvalue1",
|
||||
key2: "value2",
|
||||
};
|
||||
|
||||
const result = getUpdatedSettings(newSettings, oldSettings);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
[ArgConfigType.AGENT]: "CodeActAgent",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,94 +0,0 @@
|
||||
import { setInitialized } from "#/state/taskSlice";
|
||||
import store from "#/store";
|
||||
import ActionType from "#/types/ActionType";
|
||||
import { SupportedSettings } from "#/types/ConfigType";
|
||||
import { setByKey } from "#/state/settingsSlice";
|
||||
import toast from "#/utils/toast";
|
||||
import Socket from "./socket";
|
||||
|
||||
export type Settings = { [key: string]: string };
|
||||
|
||||
export async function fetchModels() {
|
||||
const response = await fetch(`/api/litellm-models`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchAgents() {
|
||||
const response = await fetch(`/api/agents`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// all available settings in the frontend
|
||||
// TODO: add the values to i18n to support multi languages
|
||||
const DISPLAY_MAP: { [key: string]: string } = {
|
||||
LLM_MODEL: "model",
|
||||
AGENT: "agent",
|
||||
LANGUAGE: "language",
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_MODEL: "gpt-3.5-turbo",
|
||||
AGENT: "MonologueAgent",
|
||||
LANGUAGE: "en",
|
||||
};
|
||||
|
||||
export const getSettingOrDefault = (key: string): string => {
|
||||
const value = localStorage.getItem(key);
|
||||
return value || DEFAULT_SETTINGS[key];
|
||||
};
|
||||
|
||||
export const getCurrentSettings = (): Settings => ({
|
||||
LLM_MODEL: getSettingOrDefault("LLM_MODEL"),
|
||||
AGENT: getSettingOrDefault("AGENT"),
|
||||
LANGUAGE: getSettingOrDefault("LANGUAGE"),
|
||||
});
|
||||
|
||||
// Function to merge and update settings
|
||||
export const getUpdatedSettings = (
|
||||
newSettings: Settings,
|
||||
currentSettings: Settings,
|
||||
) => {
|
||||
const updatedSettings: Settings = {};
|
||||
SupportedSettings.forEach((setting) => {
|
||||
if (
|
||||
newSettings[setting] !== currentSettings[setting] &&
|
||||
!!newSettings[setting] // check if the value is not empty/undefined
|
||||
) {
|
||||
updatedSettings[setting] = newSettings[setting];
|
||||
}
|
||||
});
|
||||
return updatedSettings;
|
||||
};
|
||||
|
||||
export const dispatchSettings = (updatedSettings: Record<string, string>) => {
|
||||
let i = 0;
|
||||
for (const [key, value] of Object.entries(updatedSettings)) {
|
||||
store.dispatch(setByKey({ key, value }));
|
||||
if (key in DISPLAY_MAP) {
|
||||
setTimeout(() => {
|
||||
toast.settingsChanged(`Set ${DISPLAY_MAP[key]} to "${value}"`);
|
||||
}, i * 500);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeAgent = () => {
|
||||
const event = { action: ActionType.INIT, args: getCurrentSettings() };
|
||||
const eventString = JSON.stringify(event);
|
||||
store.dispatch(setInitialized(false));
|
||||
Socket.send(eventString);
|
||||
};
|
||||
|
||||
// Save and send settings to the server
|
||||
export function saveSettings(newSettings: Settings): void {
|
||||
const currentSettings = getCurrentSettings();
|
||||
const updatedSettings = getUpdatedSettings(newSettings, currentSettings);
|
||||
|
||||
if (Object.keys(updatedSettings).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchSettings(updatedSettings);
|
||||
initializeAgent();
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import i18next from "i18next";
|
||||
import { ArgConfigType } from "#/types/ConfigType";
|
||||
|
||||
export const settingsSlice = createSlice({
|
||||
name: "settings",
|
||||
initialState: {
|
||||
ALL_SETTINGS: localStorage.getItem("ALL_SETTINGS") || "",
|
||||
[ArgConfigType.LLM_MODEL]:
|
||||
localStorage.getItem(ArgConfigType.LLM_MODEL) || "",
|
||||
[ArgConfigType.AGENT]: localStorage.getItem(ArgConfigType.AGENT) || "",
|
||||
[ArgConfigType.LANGUAGE]:
|
||||
localStorage.getItem(ArgConfigType.LANGUAGE) || "en",
|
||||
} as { [key: string]: string },
|
||||
reducers: {
|
||||
setByKey: (state, action) => {
|
||||
const { key, value } = action.payload;
|
||||
state[key] = value;
|
||||
localStorage.setItem(key, value);
|
||||
// language is a special case for now.
|
||||
if (key === ArgConfigType.LANGUAGE) {
|
||||
i18next.changeLanguage(value);
|
||||
}
|
||||
},
|
||||
setAllSettings: (state, action) => {
|
||||
state.ALL_SETTINGS = action.payload;
|
||||
localStorage.setItem("ALL_SETTINGS", action.payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setByKey, setAllSettings } = settingsSlice.actions;
|
||||
|
||||
export default settingsSlice.reducer;
|
||||
@ -6,7 +6,6 @@ import codeReducer from "./state/codeSlice";
|
||||
import commandReducer from "./state/commandSlice";
|
||||
import errorsReducer from "./state/errorsSlice";
|
||||
import planReducer from "./state/planSlice";
|
||||
import settingsReducer from "./state/settingsSlice";
|
||||
import taskReducer from "./state/taskSlice";
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
@ -16,7 +15,6 @@ export const rootReducer = combineReducers({
|
||||
cmd: commandReducer,
|
||||
task: taskReducer,
|
||||
errors: errorsReducer,
|
||||
settings: settingsReducer,
|
||||
plan: planReducer,
|
||||
agent: agentReducer,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user