chore(frontend): Remove old session class and some artifacts that are no longer needed (#4310)

This commit is contained in:
sp.wack 2024-10-14 21:44:23 +04:00 committed by GitHub
parent 63ff69fd97
commit 70bd710e82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1 additions and 878 deletions

View File

@ -1,10 +1,8 @@
import { screen, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import Session from "#/services/session";
import ActionType from "#/types/ActionType";
import { addAssistantMessage } from "#/state/chatSlice";
import AgentState from "#/types/AgentState";
import ChatInterface from "#/components/chat/ChatInterface";
@ -34,9 +32,6 @@ HTMLElement.prototype.scrollTo = vi.fn().mockImplementation(() => {});
const TEST_TIMESTAMP = new Date().toISOString();
describe.skip("ChatInterface", () => {
const sessionSendSpy = vi.spyOn(Session, "send");
vi.spyOn(Session, "isConnected").mockReturnValue(true);
// TODO: replace below with e.g. fake timers
// https://vitest.dev/guide/mocking#timers
// https://vitest.dev/api/vi.html#vi-usefaketimers
@ -66,19 +61,6 @@ describe.skip("ChatInterface", () => {
},
});
const userMessageEvent = {
action: ActionType.MESSAGE,
args: {
content: "my message",
images_urls: [],
timestamp: TEST_TIMESTAMP,
},
};
afterEach(() => {
sessionSendSpy.mockClear();
});
it("should render empty message list and input", () => {
renderWithProviders(<ChatInterface />);
expect(screen.queryAllByTestId("article")).toHaveLength(0);
@ -125,10 +107,6 @@ describe.skip("ChatInterface", () => {
const input = screen.getByRole("textbox");
await user.type(input, "my message");
await user.keyboard("{Enter}");
expect(sessionSendSpy).toHaveBeenCalledWith(
expect.toMatchMessageEvent(JSON.stringify(userMessageEvent)),
);
});
it("should send the user message as an event to the Session when the agent state is AWAITING_USER_INPUT", async () => {
@ -144,10 +122,6 @@ describe.skip("ChatInterface", () => {
const input = screen.getByRole("textbox");
await user.type(input, "my message");
await user.keyboard("{Enter}");
expect(sessionSendSpy).toHaveBeenCalledWith(
expect.toMatchMessageEvent(JSON.stringify(userMessageEvent)),
);
});
it("should disable the user input if agent is not initialized", async () => {
@ -168,7 +142,6 @@ describe.skip("ChatInterface", () => {
);
expect(submitButton).toBeDisabled();
expect(sessionSendSpy).not.toHaveBeenCalled();
});
it.todo("test scroll-related behaviour");

View File

@ -1,26 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { userEvent } from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import AgentState from "#/types/AgentState";
import { changeAgentState } from "#/services/agentStateService";
import ConfirmationButtons from "#/components/chat/ConfirmationButtons";
describe("ConfirmationButtons", () => {
vi.mock("#/services/agentStateService", () => ({
changeAgentState: vi.fn(),
}));
it.skip("should change agent state appropriately on button click", async () => {
const user = userEvent.setup();
render(<ConfirmationButtons />);
const confirmButton = screen.getByTestId("action-confirm-button");
const rejectButton = screen.getByTestId("action-reject-button");
await user.click(confirmButton);
expect(changeAgentState).toHaveBeenCalledWith(AgentState.USER_CONFIRMED);
await user.click(rejectButton);
expect(changeAgentState).toHaveBeenCalledWith(AgentState.USER_REJECTED);
});
});

View File

@ -1,82 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AutocompleteCombobox } from "#/components/modals/settings/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"
tooltip="tooltip"
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", async () => {
renderComponent();
const modelInput = screen.getByRole("combobox", { name: "model" });
expect(screen.queryByText("model2")).not.toBeInTheDocument();
expect(screen.queryByText("model3")).not.toBeInTheDocument();
await act(async () => {
await 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", async () => {
renderComponent();
const modelInput = screen.getByRole("combobox", { name: "model" });
expect(modelInput).toHaveValue("model1");
await act(async () => {
await userEvent.click(modelInput);
});
const model2 = screen.getByText("model2");
await act(async () => {
await userEvent.click(model2);
});
expect(onChangeMock).toHaveBeenCalledWith("model2");
});
it("should set the input value to the default key if the default key is not in the list", () => {
render(
<AutocompleteCombobox
ariaLabel="model"
items={[{ value: "m1", label: "model1" }]}
defaultKey="m2"
tooltip="tooltip"
onChange={onChangeMock}
/>,
);
const modelInput = screen.getByRole("combobox", { name: "model" });
expect(modelInput).toHaveValue("m2");
});
it.todo("should show a tooltip after 0.5 seconds of focus");
});

View File

@ -1,41 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import ActionType from "#/types/ActionType";
import { Settings, saveSettings } from "../../src/services/settings";
import Session from "../../src/services/session";
const sendSpy = vi.spyOn(Session, "send");
// @ts-expect-error - spying on private function
const setupSpy = vi.spyOn(Session, "_setupSocket").mockImplementation(() => {
// @ts-expect-error - calling a private function
Session._initializeAgent();
});
describe("startNewSession", () => {
afterEach(() => {
sendSpy.mockClear();
setupSpy.mockClear();
});
it("Should start a new session with the current settings", () => {
const settings: Settings = {
LLM_MODEL: "llm_value",
LLM_BASE_URL: "base_url",
AGENT: "agent_value",
LANGUAGE: "language_value",
LLM_API_KEY: "sk-...",
CONFIRMATION_MODE: true,
SECURITY_ANALYZER: "analyzer",
};
const event = {
action: ActionType.INIT,
args: settings,
};
saveSettings(settings);
Session.startNewSession();
expect(setupSpy).toHaveBeenCalledTimes(1);
expect(sendSpy).toHaveBeenCalledWith(JSON.stringify(event));
});
});

View File

@ -1,81 +0,0 @@
import { Autocomplete, AutocompleteItem, Tooltip } from "@nextui-org/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
type Label = "model" | "agent" | "language" | "securityanalyzer";
const LABELS: Record<Label, I18nKey> = {
model: I18nKey.CONFIGURATION$MODEL_SELECT_LABEL,
agent: I18nKey.CONFIGURATION$AGENT_SELECT_LABEL,
language: I18nKey.CONFIGURATION$LANGUAGE_SELECT_LABEL,
securityanalyzer: I18nKey.CONFIGURATION$SECURITY_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,
securityanalyzer: I18nKey.CONFIGURATION$SECURITY_SELECT_PLACEHOLDER,
};
type AutocompleteItemType = {
value: string;
label: string;
};
interface AutocompleteComboboxProps {
ariaLabel: Label;
items: AutocompleteItemType[];
defaultKey: string;
onChange: (key: string) => void;
tooltip: string;
allowCustomValue?: boolean;
disabled?: boolean;
}
export function AutocompleteCombobox({
ariaLabel,
items,
defaultKey,
onChange,
tooltip,
allowCustomValue = false,
disabled = false,
}: AutocompleteComboboxProps) {
const { t } = useTranslation();
return (
<Tooltip
content={
disabled
? `${tooltip} ${t(I18nKey.SETTINGS$DISABLED_RUNNING)}`
: tooltip
}
closeDelay={100}
delay={500}
>
<Autocomplete
aria-label={ariaLabel}
label={t(LABELS[ariaLabel])}
placeholder={t(PLACEHOLDERS[ariaLabel])}
defaultItems={items}
defaultSelectedKey={defaultKey}
inputValue={
// Find the label for the default key, otherwise use the default key itself
// This is useful when the default key is not in the list of items, in the case of a custom LLM model
items.find((item) => item.value === defaultKey)?.label || defaultKey
}
onInputChange={(val) => {
onChange(val);
}}
isDisabled={disabled}
allowsCustomValue={allowCustomValue}
>
{(item) => (
<AutocompleteItem key={item.value}>{item.label}</AutocompleteItem>
)}
</Autocomplete>
</Tooltip>
);
}

View File

@ -1,184 +0,0 @@
import { Input, Switch, Tooltip, useDisclosure } from "@nextui-org/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { FaEye, FaEyeSlash } from "react-icons/fa";
import { AvailableLanguages } from "../../../i18n";
import { I18nKey } from "../../../i18n/declaration";
import { AutocompleteCombobox } from "./AutocompleteCombobox";
import { Settings } from "#/services/settings";
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
import { ModelSelector } from "./ModelSelector";
interface SettingsFormProps {
settings: Settings;
models: string[];
agents: string[];
securityAnalyzers: string[];
disabled: boolean;
onModelChange: (model: string) => void;
onBaseURLChange: (baseURL: string) => void;
onAPIKeyChange: (apiKey: string) => void;
onAgentChange: (agent: string) => void;
onLanguageChange: (language: string) => void;
onConfirmationModeChange: (confirmationMode: boolean) => void;
onSecurityAnalyzerChange: (securityAnalyzer: string) => void;
}
function SettingsForm({
settings,
models,
agents,
securityAnalyzers,
disabled,
onModelChange,
onBaseURLChange,
onAPIKeyChange,
onAgentChange,
onLanguageChange,
onConfirmationModeChange,
onSecurityAnalyzerChange,
}: SettingsFormProps) {
const { t } = useTranslation();
const { isOpen: isVisible, onOpenChange: onVisibleChange } = useDisclosure();
const advancedAlreadyInUse = React.useMemo(() => {
const organizedModels = organizeModelsAndProviders(models);
const { provider, model } = extractModelAndProvider(
settings.LLM_MODEL || "",
);
const isKnownModel =
provider in organizedModels &&
organizedModels[provider].models.includes(model);
return (
!!settings.SECURITY_ANALYZER ||
!!settings.CONFIRMATION_MODE ||
!!settings.LLM_BASE_URL ||
(!!settings.LLM_MODEL && !isKnownModel)
);
}, [settings, models]);
const [enableAdvanced, setEnableAdvanced] =
React.useState(advancedAlreadyInUse);
React.useEffect(() => {
setEnableAdvanced(advancedAlreadyInUse);
}, [advancedAlreadyInUse]);
const handleAdvancedChange = (value: boolean) => {
setEnableAdvanced(value);
};
return (
<>
<Switch
data-testid="advanced-options-toggle"
aria-checked={enableAdvanced}
isSelected={enableAdvanced}
onValueChange={handleAdvancedChange}
>
Advanced Options
</Switch>
{enableAdvanced && (
<>
<Input
data-testid="custom-model-input"
label="Custom Model"
onValueChange={onModelChange}
defaultValue={settings.LLM_MODEL}
/>
<Input
data-testid="base-url-input"
label="Base URL"
onValueChange={onBaseURLChange}
defaultValue={settings.LLM_BASE_URL}
/>
</>
)}
{!enableAdvanced && (
<ModelSelector
isDisabled={disabled}
models={organizeModelsAndProviders(models)}
currentModel={settings.LLM_MODEL}
/>
)}
<Input
label="API Key"
isDisabled={disabled}
aria-label="apikey"
data-testid="apikey"
placeholder={t(I18nKey.SETTINGS$API_KEY_PLACEHOLDER)}
type={isVisible ? "text" : "password"}
value={settings.LLM_API_KEY || ""}
onChange={(e) => {
onAPIKeyChange(e.target.value);
}}
endContent={
<button
className="focus:outline-none"
type="button"
onClick={onVisibleChange}
>
{isVisible ? (
<FaEye className="text-2xl text-default-400 pointer-events-none" />
) : (
<FaEyeSlash className="text-2xl text-default-400 pointer-events-none" />
)}
</button>
}
/>
<AutocompleteCombobox
ariaLabel="language"
items={AvailableLanguages}
defaultKey={settings.LANGUAGE}
onChange={onLanguageChange}
tooltip={t(I18nKey.SETTINGS$LANGUAGE_TOOLTIP)}
disabled={disabled}
/>
{enableAdvanced && (
<AutocompleteCombobox
ariaLabel="agent"
items={agents.map((agent) => ({ value: agent, label: agent }))}
defaultKey={settings.AGENT}
onChange={onAgentChange}
tooltip={t(I18nKey.SETTINGS$AGENT_TOOLTIP)}
/>
)}
{enableAdvanced && (
<AutocompleteCombobox
ariaLabel="securityanalyzer"
items={securityAnalyzers.map((securityAnalyzer) => ({
value: securityAnalyzer,
label: securityAnalyzer,
}))}
defaultKey={settings.SECURITY_ANALYZER}
onChange={onSecurityAnalyzerChange}
tooltip={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
disabled={disabled}
/>
)}
{enableAdvanced && (
<Switch
aria-label="confirmationmode"
data-testid="confirmationmode"
defaultSelected={
settings.CONFIRMATION_MODE || !!settings.SECURITY_ANALYZER
}
onValueChange={onConfirmationModeChange}
isDisabled={disabled || !!settings.SECURITY_ANALYZER}
isSelected={settings.CONFIRMATION_MODE}
>
<Tooltip
content={t(I18nKey.SETTINGS$CONFIRMATION_MODE_TOOLTIP)}
closeDelay={100}
delay={500}
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</Tooltip>
</Switch>
)}
</>
);
}
export default SettingsForm;

View File

@ -1,202 +0,0 @@
import { Spinner } from "@nextui-org/react";
import i18next from "i18next";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { fetchSecurityAnalyzers } from "#/services/options";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import Session from "#/services/session";
import { RootState } from "../../../store";
import AgentState from "../../../types/AgentState";
import {
Settings,
getSettings,
getDefaultSettings,
settingsAreUpToDate,
maybeMigrateSettings,
saveSettings,
} from "#/services/settings";
import toast from "#/utils/toast";
import BaseModal from "../base-modal/BaseModal";
import SettingsForm from "./SettingsForm";
interface SettingsProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
models: string[];
agents: string[];
}
const REQUIRED_SETTINGS = ["LLM_MODEL"];
function SettingsModal({
isOpen,
onOpenChange,
models,
agents,
}: SettingsProps) {
const { t } = useTranslation();
const [securityAnalyzers, setSecurityAnalyzers] = React.useState<string[]>(
[],
);
const [settings, setSettings] = React.useState<Settings>({} as Settings);
const [agentIsRunning, setAgentIsRunning] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState(true);
const { curAgentState } = useSelector((state: RootState) => state.agent);
useEffect(() => {
maybeMigrateSettings();
setSettings(getSettings());
}, []);
useEffect(() => {
const isRunning =
curAgentState === AgentState.RUNNING ||
curAgentState === AgentState.PAUSED ||
curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
setAgentIsRunning(isRunning);
}, [curAgentState]);
React.useEffect(() => {
(async () => {
try {
setSecurityAnalyzers(await fetchSecurityAnalyzers());
} catch (error) {
toast.error("settings", t(I18nKey.CONFIGURATION$ERROR_FETCH_MODELS));
} finally {
setLoading(false);
}
})();
}, []);
const handleModelChange = (model: string) => {
setSettings((prev) => ({
...prev,
LLM_MODEL: model,
}));
};
const handleBaseURLChange = (baseURL: string) => {
setSettings((prev) => ({
...prev,
LLM_BASE_URL: baseURL,
}));
};
const handleAgentChange = (agent: string) => {
setSettings((prev) => ({ ...prev, AGENT: agent }));
};
const handleLanguageChange = (language: string) => {
const key =
AvailableLanguages.find((lang) => lang.label === language)?.value ||
language;
// The appropriate key is assigned when the user selects a language.
// Otherwise, their input is reflected in the inputValue field of the Autocomplete component.
setSettings((prev) => ({ ...prev, LANGUAGE: key }));
};
const handleAPIKeyChange = (key: string) => {
setSettings((prev) => ({ ...prev, LLM_API_KEY: key }));
};
const handleConfirmationModeChange = (confirmationMode: boolean) => {
setSettings((prev) => ({ ...prev, CONFIRMATION_MODE: confirmationMode }));
};
const handleSecurityAnalyzerChange = (securityAnalyzer: string) => {
setSettings((prev) => ({
...prev,
CONFIRMATION_MODE: true,
SECURITY_ANALYZER: securityAnalyzer,
}));
};
const handleResetSettings = () => {
setSettings(getDefaultSettings);
};
const handleSaveSettings = () => {
saveSettings(settings);
i18next.changeLanguage(settings.LANGUAGE);
Session.startNewSession();
localStorage.setItem(
`API_KEY_${settings.LLM_MODEL || models[0]}`,
settings.LLM_API_KEY,
);
};
let subtitle = "";
if (loading) {
subtitle = t(I18nKey.CONFIGURATION$AGENT_LOADING);
} else if (agentIsRunning) {
subtitle = t(I18nKey.CONFIGURATION$AGENT_RUNNING);
} else if (!settingsAreUpToDate()) {
subtitle = t(I18nKey.CONFIGURATION$SETTINGS_NEED_UPDATE_MESSAGE);
}
const saveIsDisabled = REQUIRED_SETTINGS.some(
(key) => !settings[key as keyof Settings],
);
return (
<BaseModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title={t(I18nKey.CONFIGURATION$MODAL_TITLE)}
isDismissable={settingsAreUpToDate()}
subtitle={subtitle}
actions={
loading
? []
: [
{
label: t(I18nKey.CONFIGURATION$MODAL_SAVE_BUTTON_LABEL),
action: handleSaveSettings,
isDisabled: saveIsDisabled,
closeAfterAction: true,
className: "bg-primary rounded-lg",
},
{
label: t(I18nKey.CONFIGURATION$MODAL_RESET_BUTTON_LABEL),
action: handleResetSettings,
closeAfterAction: false,
className: "bg-neutral-500 rounded-lg",
},
{
label: t(I18nKey.CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL),
action: () => {
setSettings(getSettings()); // reset settings from any changes
},
isDisabled: !settingsAreUpToDate(),
closeAfterAction: true,
className: "bg-rose-600 rounded-lg",
},
]
}
>
{loading && <Spinner />}
{!loading && (
<SettingsForm
disabled={agentIsRunning}
settings={settings}
models={models}
agents={agents}
securityAnalyzers={securityAnalyzers}
onModelChange={handleModelChange}
onBaseURLChange={handleBaseURLChange}
onAgentChange={handleAgentChange}
onLanguageChange={handleLanguageChange}
onAPIKeyChange={handleAPIKeyChange}
onConfirmationModeChange={handleConfirmationModeChange}
onSecurityAnalyzerChange={handleSecurityAnalyzerChange}
/>
)}
</BaseModal>
);
}
export default SettingsModal;

View File

@ -1,21 +1,8 @@
import ActionType from "#/types/ActionType";
import AgentState from "#/types/AgentState";
import Session from "./session";
const INIT_DELAY = 1000;
export const generateAgentStateChangeEvent = (state: AgentState) =>
JSON.stringify({
action: ActionType.CHANGE_AGENT_STATE,
args: { agent_state: state },
});
export function changeAgentState(state: AgentState): void {
const eventString = generateAgentStateChangeEvent(state);
Session.send(eventString);
if (state === AgentState.STOPPED) {
setTimeout(() => {
Session.startNewSession();
}, INIT_DELAY);
}
}

View File

@ -1,221 +0,0 @@
import i18next from "i18next";
import toast from "#/utils/toast";
import { handleAssistantMessage } from "./actions";
import { getToken, setToken, clearToken } from "./auth";
import ActionType from "#/types/ActionType";
import { getSettings } from "./settings";
import { I18nKey } from "#/i18n/declaration";
const translate = (key: I18nKey) => i18next.t(key);
// Define a type for the messages
type Message = {
action: ActionType;
args: Record<string, unknown>;
};
class Session {
private static _socket: WebSocket | null = null;
private static _latest_event_id: number = -1;
private static _messageQueue: Message[] = [];
public static _history: Record<string, unknown>[] = [];
// callbacks contain a list of callable functions
// event: function, like:
// open: [function1, function2]
// message: [function1, function2]
private static callbacks: {
[K in keyof WebSocketEventMap]: ((data: WebSocketEventMap[K]) => void)[];
} = {
open: [],
message: [],
error: [],
close: [],
};
private static _connecting = false;
private static _disconnecting = false;
public static restoreOrStartNewSession() {
if (Session.isConnected()) {
Session.disconnect();
}
Session._connect();
}
public static startNewSession() {
clearToken();
Session.restoreOrStartNewSession();
}
private static _initializeAgent = () => {
const settings = getSettings();
const event = {
action: ActionType.INIT,
args: {
...settings,
},
};
const eventString = JSON.stringify(event);
Session.send(eventString);
};
private static _connect(): void {
if (Session.isConnected()) return;
Session._connecting = true;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
let wsURL = `${protocol}//${window.location.host}/ws`;
const token = getToken();
if (token) {
wsURL += `?token=${token}`;
if (Session._latest_event_id !== -1) {
wsURL += `&latest_event_id=${Session._latest_event_id}`;
}
}
Session._socket = new WebSocket(wsURL);
Session._setupSocket();
}
private static _setupSocket(): void {
if (!Session._socket) {
throw new Error(
translate(I18nKey.SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE),
);
}
Session._socket.onopen = (e) => {
toast.success("ws", translate(I18nKey.SESSION$SERVER_CONNECTED_MESSAGE));
Session._connecting = false;
Session._initializeAgent();
Session._flushQueue();
Session.callbacks.open?.forEach((callback) => {
callback(e);
});
};
Session._socket.onmessage = (e) => {
let data = null;
try {
data = JSON.parse(e.data);
Session._history.push(data);
} catch (err) {
toast.error(
"ws",
translate(I18nKey.SESSION$SESSION_HANDLING_ERROR_MESSAGE),
);
return;
}
if (data.error && data.error_code === 401) {
Session._latest_event_id = -1;
clearToken();
} else if (data.token) {
setToken(data.token);
} else {
if (data.id !== undefined) {
Session._latest_event_id = data.id;
}
handleAssistantMessage(data);
}
};
Session._socket.onerror = () => {
// TODO report error
toast.error(
"ws",
translate(I18nKey.SESSION$SESSION_CONNECTION_ERROR_MESSAGE),
);
};
Session._socket.onclose = () => {
if (!Session._disconnecting) {
setTimeout(() => {
Session.restoreOrStartNewSession();
}, 3000); // Reconnect after 3 seconds
}
Session._disconnecting = false;
};
}
static isConnected(): boolean {
return (
Session._socket !== null && Session._socket.readyState === WebSocket.OPEN
);
}
static disconnect(): void {
Session._disconnecting = true;
if (Session._socket) {
Session._socket.close();
}
Session._socket = null;
}
private static _flushQueue(): void {
while (Session._messageQueue.length > 0) {
const message = Session._messageQueue.shift();
if (message) {
setTimeout(() => Session.send(JSON.stringify(message)), 1000);
}
}
}
static send(message: string): void {
const messageObject: Message = JSON.parse(message);
if (Session._connecting) {
Session._messageQueue.push(messageObject);
return;
}
if (!Session.isConnected()) {
throw new Error(
translate(I18nKey.SESSION$SESSION_CONNECTION_ERROR_MESSAGE),
);
}
if (Session.isConnected()) {
Session._socket?.send(message);
Session._history.push(JSON.parse(message));
} else {
toast.error(
"ws",
translate(I18nKey.SESSION$SESSION_CONNECTION_ERROR_MESSAGE),
);
}
}
static addEventListener(
event: string,
callback: (e: MessageEvent) => void,
): void {
Session._socket?.addEventListener(
event as keyof WebSocketEventMap,
callback as (
this: WebSocket,
ev: WebSocketEventMap[keyof WebSocketEventMap],
) => never,
);
}
static removeEventListener(
event: string,
listener: (e: Event) => void,
): void {
Session._socket?.removeEventListener(event, listener);
}
static registerCallback<K extends keyof WebSocketEventMap>(
event: K,
callbacks: ((data: WebSocketEventMap[K]) => void)[],
): void {
if (Session.callbacks[event] === undefined) {
return;
}
Session.callbacks[event].push(...callbacks);
}
}
export default Session;