fix: bugs (#818)

* feat: add chat config modal

* feat: save chat config in localStorage

* feat: remove <ConfigPage/>

* feat: overwrite chat config with brain

* fix(SettingsPage): upload payload keys

* fix: update default brain marker logic

* feat: set new created brain as current selected brain
This commit is contained in:
Mamadou DICKO 2023-08-01 16:25:02 +02:00 committed by GitHub
parent 130a3e0938
commit edcbb30e97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 453 additions and 802 deletions

View File

@ -2,11 +2,12 @@ from typing import Any, List, Optional
from uuid import UUID
from logger import get_logger
from models.settings import BrainRateLimiting, CommonsDep, common_dependencies
from models.users import User
from pydantic import BaseModel
from utils.vectors import get_unique_files_from_vector_ids
from models.settings import BrainRateLimiting, CommonsDep, common_dependencies
from models.users import User
logger = get_logger(__name__)
@ -111,7 +112,9 @@ class Brain(BaseModel):
.filter("brain_id", "eq", self.id)
.execute()
)
return response.data
if response.data == []:
return None
return response.data[0]
def delete_brain(self, user_id):
results = (

View File

@ -20,7 +20,9 @@ def resend_invitation_email(
brain_url = get_brain_url(origin, brain_subscription.brain_id)
invitation_brain_client = Brain(id=brain_subscription.brain_id)
invitation_brain = invitation_brain_client.get_brain_details()[0]
invitation_brain = invitation_brain_client.get_brain_details()
if invitation_brain is None:
raise Exception("Brain not found")
brain_name = invitation_brain["name"]
html_body = f"""

View File

@ -73,15 +73,16 @@ async def get_brain_endpoint(
history, which includes the brain messages exchanged in the brain.
"""
brain = Brain(id=brain_id)
brains = brain.get_brain_details()
if len(brains) > 0:
return brains[0]
else:
return HTTPException(
brain_details = brain.get_brain_details()
if brain_details is None:
raise HTTPException(
status_code=404,
detail="Brain not found",
detail="Brain details not found",
)
return brain_details
# create new brain
@brain_router.post("/brains/", dependencies=[Depends(AuthBearer())], tags=["Brain"])

View File

@ -9,7 +9,7 @@ from auth import AuthBearer, get_current_user
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import StreamingResponse
from llm.openai import OpenAIBrainPicking
from models.brains import get_default_user_brain_or_create_new
from models.brains import Brain, get_default_user_brain_or_create_new
from models.chat import Chat, ChatHistory
from models.chats import ChatQuestion
from models.settings import LLMSettings, common_dependencies
@ -19,12 +19,12 @@ from repository.chat.get_chat_by_id import get_chat_by_id
from repository.chat.get_chat_history import get_chat_history
from repository.chat.get_user_chats import get_user_chats
from repository.chat.update_chat import ChatUpdatableProperties, update_chat
from repository.user_identity.get_user_identity import get_user_identity
chat_router = APIRouter()
class NullableUUID(UUID):
@classmethod
def __get_validators__(cls):
yield cls.validate
@ -180,10 +180,38 @@ async def create_question_handler(
| None = Query(..., description="The ID of the brain"),
current_user: User = Depends(get_current_user),
) -> ChatHistory:
"""
Add a new question to the chat.
"""
# Retrieve user's OpenAI API key
current_user.user_openai_api_key = request.headers.get("Openai-Api-Key")
brain = Brain(id=brain_id)
if not current_user.user_openai_api_key:
brain_details = brain.get_brain_details()
if brain_details:
current_user.user_openai_api_key = brain_details["openai_api_key"]
if not current_user.user_openai_api_key:
user_identity = get_user_identity(current_user.id)
if user_identity is not None:
current_user.user_openai_api_key = user_identity.openai_api_key
# Retrieve chat model (temperature, max_tokens, model)
if (
not chat_question.model
or not chat_question.temperature
or not chat_question.max_tokens
):
# TODO: create ChatConfig class (pick config from brain or user or chat) and use it here
chat_question.model = chat_question.model or brain.model or "gpt-3.5-turbo-0613"
chat_question.temperature = chat_question.temperature or brain.temperature or 0
chat_question.max_tokens = chat_question.max_tokens or brain.max_tokens or 256
try:
check_user_limit(current_user)
llm_settings = LLMSettings()
LLMSettings()
if not brain_id:
brain_id = get_default_user_brain_or_create_new(current_user).id
@ -227,14 +255,38 @@ async def create_stream_question_handler(
) -> StreamingResponse:
# TODO: check if the user has access to the brain
# Retrieve user's OpenAI API key
current_user.user_openai_api_key = request.headers.get("Openai-Api-Key")
brain = Brain(id=brain_id)
if not current_user.user_openai_api_key:
brain_details = brain.get_brain_details()
if brain_details:
current_user.user_openai_api_key = brain_details["openai_api_key"]
if not current_user.user_openai_api_key:
user_identity = get_user_identity(current_user.id)
if user_identity is not None:
current_user.user_openai_api_key = user_identity.openai_api_key
# Retrieve chat model (temperature, max_tokens, model)
if (
not chat_question.model
or not chat_question.temperature
or not chat_question.max_tokens
):
# TODO: create ChatConfig class (pick config from brain or user or chat) and use it here
chat_question.model = chat_question.model or brain.model or "gpt-3.5-turbo-0613"
chat_question.temperature = chat_question.temperature or brain.temperature or 0
chat_question.max_tokens = chat_question.max_tokens or brain.max_tokens or 256
try:
user_openai_api_key = request.headers.get("Openai-Api-Key")
logger.info(f"Streaming request for {chat_question.model}")
check_user_limit(current_user)
if not brain_id:
brain_id = get_default_user_brain_or_create_new(current_user).id
gpt_answer_generator = OpenAIBrainPicking(
chat_id=str(chat_id),
model=chat_question.model,

View File

@ -173,13 +173,13 @@ def get_user_invitation(brain_id: UUID, current_user: User = Depends(get_current
brain = Brain(id=brain_id)
brain_details = brain.get_brain_details()
if len(brain_details) == 0:
if brain_details is None:
raise HTTPException(
status_code=404,
detail="Brain not found while trying to get invitation",
)
return {"name": brain_details[0]["name"], "rights": invitation["rights"]}
return {"name": brain_details["name"], "rights": invitation["rights"]}
@subscription_router.post(

View File

@ -62,7 +62,9 @@ async def upload_file(
else:
openai_api_key = request.headers.get("Openai-Api-Key", None)
if openai_api_key is None:
openai_api_key = brain.get_brain_details()["openai_api_key"]
brain_details = brain.get_brain_details()
if brain_details:
openai_api_key = brain_details["openai_api_key"]
if openai_api_key is None:
openai_api_key = get_user_identity(current_user.id).openai_api_key

View File

@ -8,7 +8,6 @@ import { useForm } from "react-hook-form";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { useBrainConfig } from "@/lib/context/BrainConfigProvider";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useBrainProvider } from "@/lib/context/BrainProvider/hooks/useBrainProvider";
import { Brain } from "@/lib/context/BrainProvider/types";
import { defineMaxTokens } from "@/lib/helpers/defineMexTokens";
import { useToast } from "@/lib/hooks";
@ -25,7 +24,8 @@ export const useSettingsTab = ({ brainId }: UseSettingsTabProps) => {
const formRef = useRef<HTMLFormElement>(null);
const { setAsDefaultBrain, getBrain, updateBrain } = useBrainApi();
const { config } = useBrainConfig();
const { fetchAllBrains, fetchDefaultBrain } = useBrainContext();
const { fetchAllBrains, fetchDefaultBrain, defaultBrainId } =
useBrainContext();
const defaultValues = {
...config,
@ -58,15 +58,21 @@ export const useSettingsTab = ({ brainId }: UseSettingsTabProps) => {
return;
}
if (brainKey === "max_tokens") {
if (brain["max_tokens"] !== undefined) {
setValue("maxTokens", brain["max_tokens"]);
}
} else {
// @ts-expect-error bad type inference from typescript
// eslint-disable-next-line
setValue(key, brain[key]);
if (brainKey === "max_tokens" && brain["max_tokens"] !== undefined) {
setValue("maxTokens", brain["max_tokens"]);
continue;
}
if (
brainKey === "openai_api_key" &&
brain["openai_api_key"] !== undefined
) {
setValue("openAiKey", brain["openai_api_key"]);
continue;
}
// @ts-expect-error bad type inference from typescript
// eslint-disable-next-line
setValue(key, brain[key]);
}
};
void fetchBrain();
@ -131,7 +137,17 @@ export const useSettingsTab = ({ brainId }: UseSettingsTabProps) => {
try {
setIsUpdating(true);
await updateBrain(brainId, getValues());
const {
maxTokens: max_tokens,
openAiKey: openai_api_key,
...otherConfigs
} = getValues();
await updateBrain(brainId, {
...otherConfigs,
max_tokens,
openai_api_key,
});
publish({
variant: "success",
@ -160,7 +176,6 @@ export const useSettingsTab = ({ brainId }: UseSettingsTabProps) => {
setIsUpdating(false);
}
};
const { defaultBrainId } = useBrainProvider();
const isDefaultBrain = defaultBrainId === brainId;
useEffect(() => {

View File

@ -1,10 +1,31 @@
/* eslint-disable max-lines */
import { fireEvent, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BrainProvider } from "@/lib/context";
import { BrainConfigProvider } from "@/lib/context/BrainConfigProvider";
import { ChatInput } from "../index";
const addQuestionMock = vi.fn((...params: unknown[]) => ({ params }));
vi.mock("@/lib/hooks", async () => {
const actual = await vi.importActual<typeof import("@/lib/hooks")>(
"@/lib/hooks"
);
return {
...actual,
useAxios: () => ({
axiosInstance: {
get: vi.fn(() => ({
data: {},
})),
},
}),
};
});
vi.mock("@/app/chat/[chatId]/hooks/useChat", () => ({
useChat: () => ({
addQuestion: (...params: unknown[]) => addQuestionMock(...params),
@ -12,14 +33,28 @@ vi.mock("@/app/chat/[chatId]/hooks/useChat", () => ({
}),
}));
afterEach(() => {
addQuestionMock.mockClear();
});
const mockUseSupabase = vi.fn(() => ({
session: {},
}));
vi.mock("@/lib/context/SupabaseProvider", () => ({
useSupabase: () => mockUseSupabase(),
}));
describe("ChatInput", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should render correctly", () => {
// Rendering the ChatInput component
const { getByTestId } = render(<ChatInput />);
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInputForm = getByTestId("chat-input-form");
expect(chatInputForm).toBeDefined();
@ -30,15 +65,18 @@ describe("ChatInput", () => {
const submitButton = getByTestId("submit-button");
expect(submitButton).toBeDefined();
const configButton = getByTestId("config-button");
expect(configButton).toBeDefined();
const micButton = getByTestId("mic-button");
expect(micButton).toBeDefined();
});
it("should not call addQuestion on form submit when message is empty", () => {
const { getByTestId } = render(<ChatInput />);
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInputForm = getByTestId("chat-input-form");
fireEvent.submit(chatInputForm);
@ -47,7 +85,13 @@ describe("ChatInput", () => {
});
it("should call addQuestion once on form submit when message is not empty", () => {
const { getByTestId } = render(<ChatInput />);
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInput = getByTestId("chat-input");
fireEvent.change(chatInput, { target: { value: "Test question" } });
const chatInputForm = getByTestId("chat-input-form");
@ -65,7 +109,13 @@ describe("ChatInput", () => {
// Mocking the addQuestion function
// Rendering the ChatInput component with the mock function
const { getByTestId } = render(<ChatInput />);
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInput = getByTestId("chat-input");
fireEvent.change(chatInput, { target: { value: "Another test question" } });
@ -80,7 +130,13 @@ describe("ChatInput", () => {
});
it('should not submit a question when "Enter" key is pressed with shift', () => {
const { getByTestId } = render(<ChatInput />);
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const inputElement = getByTestId("chat-input");

View File

@ -1,19 +0,0 @@
"use client";
import Link from "next/link";
import { MdSettings } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
export const ConfigButton = (): JSX.Element => {
return (
<Link href={"/config"}>
<Button
className="p-2 sm:px-3"
variant={"tertiary"}
data-testid="config-button"
>
<MdSettings className="text-lg sm:text-xl lg:text-2xl" />
</Button>
</Link>
);
};

View File

@ -0,0 +1,101 @@
/* eslint-disable max-lines */
import { MdCheck, MdSettings } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { Modal } from "@/lib/components/ui/Modal";
import { models } from "@/lib/context/BrainConfigProvider/types";
import { defineMaxTokens } from "@/lib/helpers/defineMexTokens";
import { useConfigModal } from "./hooks/useConfigModal";
export const ConfigModal = ({ chatId }: { chatId?: string }): JSX.Element => {
const {
handleSubmit,
isConfigModalOpen,
setIsConfigModalOpen,
register,
temperature,
maxTokens,
model,
} = useConfigModal(chatId);
if (chatId === undefined) {
return <div />;
}
return (
<Modal
Trigger={
<Button
className="p-2 sm:px-3"
variant={"tertiary"}
data-testid="config-button"
>
<MdSettings className="text-lg sm:text-xl lg:text-2xl" />
</Button>
}
title="Chat configuration"
desc="Adjust your chat settings"
isOpen={isConfigModalOpen}
setOpen={setIsConfigModalOpen}
CloseTrigger={<div />}
>
<form
onSubmit={(e) => {
void handleSubmit(e);
setIsConfigModalOpen(false);
}}
className="mt-10 flex flex-col items-center gap-2"
>
<fieldset className="w-full flex flex-col">
<label className="flex-1 text-sm" htmlFor="model">
Model
</label>
<select
id="model"
{...register("model")}
className="px-5 py-2 dark:bg-gray-700 bg-gray-200 rounded-md"
>
{models.map((availableModel) => (
<option value={availableModel} key={availableModel}>
{availableModel}
</option>
))}
</select>
</fieldset>
<fieldset className="w-full flex mt-4">
<label className="flex-1" htmlFor="temp">
Temperature: {temperature}
</label>
<input
id="temp"
type="range"
min="0"
max="1"
step="0.01"
value={temperature}
{...register("temperature")}
/>
</fieldset>
<fieldset className="w-full flex mt-4">
<label className="flex-1" htmlFor="tokens">
Max tokens: {maxTokens}
</label>
<input
type="range"
min="10"
max={defineMaxTokens(model ?? "gpt-3.5-turbo-0613")}
value={maxTokens}
{...register("maxTokens")}
/>
</fieldset>
<Button className="mt-12 self-end" type="submit">
Save
<MdCheck className="text-xl" />
</Button>
</form>
</Modal>
);
};

View File

@ -0,0 +1,109 @@
/* eslint-disable max-lines */
import { FormEvent, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import {
getChatConfigFromLocalStorage,
saveChatConfigInLocalStorage,
} from "@/lib/api/chat/chat.local";
import { useBrainConfig } from "@/lib/context/BrainConfigProvider";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { ChatConfig } from "@/lib/context/ChatProvider/types";
import { defineMaxTokens } from "@/lib/helpers/defineMexTokens";
import { useToast } from "@/lib/hooks";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useConfigModal = (chatId?: string) => {
const { publish } = useToast();
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const { config } = useBrainConfig();
const { getBrain } = useBrainApi();
const { currentBrain } = useBrainContext();
const defaultValues: ChatConfig = {};
const { register, watch, setValue } = useForm({
defaultValues,
});
const model = watch("model");
const temperature = watch("temperature");
const maxTokens = watch("maxTokens");
useEffect(() => {
const fetchChatConfig = async () => {
if (chatId === undefined) {
return;
}
const chatConfig = getChatConfigFromLocalStorage(chatId);
if (chatConfig !== undefined) {
setValue("model", chatConfig.model);
setValue("temperature", chatConfig.temperature);
setValue("maxTokens", chatConfig.maxTokens);
} else {
if (currentBrain === undefined) {
return;
}
const relatedBrainConfig = await getBrain(currentBrain.id);
if (relatedBrainConfig === undefined) {
return;
}
setValue("model", relatedBrainConfig.model ?? config.model);
setValue(
"temperature",
relatedBrainConfig.temperature ?? config.temperature
);
setValue(
"maxTokens",
relatedBrainConfig.max_tokens ?? config.maxTokens
);
}
};
void fetchChatConfig();
}, []);
useEffect(() => {
if (maxTokens === undefined || model === undefined) {
return;
}
setValue("maxTokens", Math.min(maxTokens, defineMaxTokens(model)));
}, [maxTokens, model, setValue]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (chatId === undefined) {
return;
}
try {
saveChatConfigInLocalStorage(chatId, {
maxTokens,
model,
temperature,
});
publish({
variant: "success",
text: "Chat config successfully updated",
});
} catch (err) {
publish({
variant: "danger",
text: "An error occured while updating chat config",
});
}
};
return {
isConfigModalOpen,
setIsConfigModalOpen,
handleSubmit,
register,
model,
temperature,
maxTokens,
};
};

View File

@ -0,0 +1 @@
export * from "./ConfigModal";

View File

@ -4,12 +4,12 @@ import Button from "@/lib/components/ui/Button";
import { useChat } from "@/app/chat/[chatId]/hooks/useChat";
import { useState } from "react";
import { ConfigButton } from "./components/ConfigButton";
import { ConfigModal } from "./components/ConfigModal";
import { MicButton } from "./components/MicButton/MicButton";
export const ChatInput = (): JSX.Element => {
const [message, setMessage] = useState<string>(""); // for optimistic updates
const { addQuestion, generatingAnswer } = useChat();
const [message, setMessage] = useState<string>("");
const { addQuestion, generatingAnswer, chatId } = useChat();
const submitQuestion = () => {
if (message.length === 0) return;
@ -52,7 +52,7 @@ export const ChatInput = (): JSX.Element => {
</Button>
<div className="flex items-center">
<MicButton setMessage={setMessage} />
<ConfigButton />
<ConfigModal chatId={chatId} />
</div>
</form>
);

View File

@ -3,20 +3,15 @@ import { AxiosError } from "axios";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { getChatConfigFromLocalStorage } from "@/lib/api/chat/chat.local";
import { useChatApi } from "@/lib/api/chat/useChatApi";
import { useBrainConfig } from "@/lib/context/BrainConfigProvider/hooks/useBrainConfig";
import { useChatContext } from "@/lib/context/ChatProvider/hooks/useChatContext";
import { useToast } from "@/lib/hooks";
import { useEventTracking } from "@/services/analytics/useEventTracking";
import { useQuestion } from "./useQuestion";
import { ChatQuestion } from "../types";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useChat = () => {
const { track } = useEventTracking();
@ -25,9 +20,7 @@ export const useChat = () => {
params?.chatId as string | undefined
);
const [generatingAnswer, setGeneratingAnswer] = useState(false);
const {
config: { maxTokens, model, temperature },
} = useBrainConfig();
const { history, setHistory } = useChatContext();
const { publish } = useToast();
const { createChat, getHistory } = useChatApi();
@ -51,13 +44,6 @@ export const useChat = () => {
}, [chatId, setHistory]);
const addQuestion = async (question: string, callback?: () => void) => {
const chatQuestion: ChatQuestion = {
model,
question,
temperature,
max_tokens: maxTokens,
};
try {
setGeneratingAnswer(true);
@ -72,10 +58,16 @@ export const useChat = () => {
}
void track("QUESTION_ASKED");
const chatConfig = getChatConfigFromLocalStorage(currentChatId);
const chatQuestion: ChatQuestion = {
model: chatConfig?.model,
question,
temperature: chatConfig?.temperature,
max_tokens: chatConfig?.maxTokens,
};
await addStreamQuestion(currentChatId, chatQuestion);
callback?.();
} catch (error) {
@ -103,5 +95,6 @@ export const useChat = () => {
history,
addQuestion,
generatingAnswer,
chatId,
};
};

View File

@ -1,10 +1,10 @@
import { UUID } from "crypto";
export type ChatQuestion = {
model: string;
model?: string;
question?: string;
temperature: number;
max_tokens: number;
temperature?: number;
max_tokens?: number;
};
export type ChatHistory = {
chat_id: string;

View File

@ -1,73 +0,0 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BrainConfigProvider } from "@/lib/context/BrainConfigProvider";
import ConfigPage from "../page";
// Mocks ConfirmButton as JSX.Element
const ConfirmFormMock = vi.fn<[], JSX.Element>(() => <div />);
const ConfirmTitleMock = vi.fn(() => <div />);
const ApiKeyConfig = vi.fn(() => <div />);
const redirectMock = vi.fn((props: unknown) => ({ props }));
const useSupabaseMock = vi.fn(() => ({
session: null,
}));
vi.mock("next/navigation", () => ({
redirect: (props: unknown) => redirectMock(props),
useRouter: vi.fn(() => ({
redirect: redirectMock,
})),
}));
vi.mock("@/lib/context/SupabaseProvider", () => ({
useSupabase: () => useSupabaseMock(),
}));
describe("ConfigPage", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should redirect to /login if session is null", () => {
render(
<BrainConfigProvider>
<ConfigPage />
</BrainConfigProvider>
);
expect(redirectMock).toHaveBeenCalledTimes(1);
expect(redirectMock).toHaveBeenCalledWith("/login");
});
it("should render config page if user is connected", () => {
useSupabaseMock.mockReturnValue({
// @ts-ignore we don't actually need parameters
session: {
user: {},
},
});
vi.mock("../components", () => ({
ConfigForm: () => ConfirmFormMock(),
ConfigTitle: () => ConfirmTitleMock(),
ApiKeyConfig: () => ApiKeyConfig(),
}));
render(
<BrainConfigProvider>
<ConfigPage />
</BrainConfigProvider>
);
expect(redirectMock).not.toHaveBeenCalled();
expect(ConfirmTitleMock).toHaveBeenCalledTimes(1);
expect(ConfirmFormMock).toHaveBeenCalledTimes(1);
expect(ApiKeyConfig).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,49 +0,0 @@
"use client";
import Button from "@/lib/components/ui/Button";
import { useApiKeyConfig } from "./hooks/useApiKeyConfig";
export const ApiKeyConfig = (): JSX.Element => {
const {
apiKey,
handleCopyClick,
handleCreateClick,
} = useApiKeyConfig();
return (
<>
<div className="border-b border-gray-300 w-full max-w-xl mb-8">
<p className="text-center text-gray-600 uppercase tracking-wide font-semibold">
API Key Config
</p>
</div>
<div className="flex justify-between items-center">
<div className="flex items-center space-x-4">
{apiKey === "" && (
<Button
data-testid="create-new-key"
variant="secondary"
onClick={() => void handleCreateClick()}
>
Create New Key
</Button>
)}
</div>
{apiKey !== "" && (
<div className="flex items-center space-x-4">
<span className="text-gray-600">{apiKey}</span>
<Button
data-testid="copy-api-key-button"
variant="secondary"
onClick={handleCopyClick}
>
📋
</Button>
</div>
)}
</div>
</>
);
};

View File

@ -1,52 +0,0 @@
import { fireEvent, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiKeyConfig } from "../ApiKeyConfig";
const handleCreateClickMock = vi.fn(() => ({}));
const handleCopyClickMock = vi.fn(() => ({}));
const useApiKeyConfigMock = vi.fn(() => ({
apiKey: "",
handleCreateClick: () => handleCreateClickMock(),
handleCopyClick: () => handleCopyClickMock(),
}));
vi.mock("../hooks/useApiKeyConfig", () => ({
useApiKeyConfig: () => useApiKeyConfigMock(),
}));
describe("ApiKeyConfig", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should render ApiConfig Component", () => {
const { getByText } = render(<ApiKeyConfig />);
expect(getByText("API Key Config")).toBeDefined();
});
it("renders 'Create New Key' button when apiKey is empty", () => {
const { getByTestId } = render(<ApiKeyConfig />);
const createButton = getByTestId("create-new-key");
expect(createButton).toBeDefined();
fireEvent.click(createButton);
expect(handleCreateClickMock).toHaveBeenCalledTimes(1);
expect(handleCreateClickMock).toHaveBeenCalledWith();
});
it('renders "Copy" button when apiKey is not empty', () => {
useApiKeyConfigMock.mockReturnValue({
apiKey: "123456789",
handleCreateClick: () => handleCreateClickMock(),
handleCopyClick: () => handleCopyClickMock(),
});
const { getByTestId } = render(<ApiKeyConfig />);
const copyButton = getByTestId("copy-api-key-button");
expect(copyButton).toBeDefined();
fireEvent.click(copyButton);
expect(handleCopyClickMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,67 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useApiKeyConfig } from "../useApiKeyConfig";
const createApiKeyMock = vi.fn(() => "dummyApiKey");
const trackMock = vi.fn((props: unknown) => ({ props }));
const useAuthApiMock = vi.fn(() => ({
createApiKey: () => createApiKeyMock(),
}));
const useEventTrackingMock = vi.fn(() => ({
track: (props: unknown) => trackMock(props),
}));
vi.mock("@/lib/api/auth/useAuthApi", () => ({
useAuthApi: () => useAuthApiMock(),
}));
vi.mock("@/services/analytics/useEventTracking", () => ({
useEventTracking: () => useEventTrackingMock(),
}));
describe("useApiKeyConfig", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should set the apiKey when handleCreateClick is called", async () => {
const { result } = renderHook(() => useApiKeyConfig());
await act(async () => {
await result.current.handleCreateClick();
});
expect(createApiKeyMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith("CREATE_API_KEY");
expect(result.current.apiKey).toBe("dummyApiKey");
});
it("should call copyToClipboard when handleCopyClick is called with a non-empty apiKey", () => {
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
return {
...actual,
useState: () => ["dummyApiKey", vi.fn()],
};
});
//@ts-ignore - clipboard is not actually readonly
global.navigator.clipboard = {
writeText: vi.fn(),
};
const { result } = renderHook(() => useApiKeyConfig());
act(() => result.current.handleCopyClick());
expect(trackMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith("COPY_API_KEY");
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(
"dummyApiKey"
);
});
});

View File

@ -1,45 +0,0 @@
import { useState } from "react";
import { useAuthApi } from "@/lib/api/auth/useAuthApi";
import { useEventTracking } from "@/services/analytics/useEventTracking";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useApiKeyConfig = () => {
const [apiKey, setApiKey] = useState("");
const [openAiApiKey, setOpenAiApiKey] = useState("");
const { track } = useEventTracking();
const { createApiKey } = useAuthApi();
const handleCreateClick = async () => {
try {
void track("CREATE_API_KEY");
const createdApiKey = await createApiKey();
setApiKey(createdApiKey);
} catch (error) {
console.error("Error creating API key: ", error);
}
};
const copyToClipboard = async (text: string) => {
try {
void track("COPY_API_KEY");
await navigator.clipboard.writeText(text);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const handleCopyClick = () => {
if (apiKey !== "") {
void copyToClipboard(apiKey);
}
};
return {
handleCreateClick,
apiKey,
handleCopyClick,
openAiApiKey,
setOpenAiApiKey,
};
};

View File

@ -1,56 +0,0 @@
/* eslint-disable */
"use client";
import { UseFormRegister } from "react-hook-form";
import Field from "@/lib/components/ui/Field";
import { BrainConfig } from "@/lib/context/BrainConfigProvider/types";
interface BackendConfigProps {
register: UseFormRegister<BrainConfig>;
}
export const BackendConfig = ({
register,
}: BackendConfigProps): JSX.Element => {
return (
<>
<div className="border-b border-gray-300 mt-8 mb-8">
<p className="text-center text-gray-600 uppercase tracking-wide font-semibold">
Backend config
</p>
</div>
<Field
type="text"
placeholder="Backend URL"
className="w-full"
label="Backend URL"
{...register("backendUrl")}
/>
<Field
type="text"
placeholder="Supabase URL"
className="w-full"
label="Supabase URL"
{...register("supabaseUrl")}
/>
<Field
type="text"
placeholder="Supabase key"
className="w-full"
label="Supabase key"
{...register("supabaseKey")}
/>
<label className="flex items-center">
<input
type="checkbox"
checked
name="keepLocal"
onChange={() => alert("Coming soon")}
className="form-checkbox h-5 w-5 text-indigo-600 rounded focus:ring-2 focus:ring-indigo-400"
/>
<span className="ml-2 text-gray-700">Keep in local</span>
</label>
</>
);
};

View File

@ -1,24 +0,0 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { BackendConfig } from "../BackendConfig";
const registerMock = vi.fn(() => void 0);
describe("BackendConfig", () => {
it("renders the component with fields and labels", () => {
//@ts-expect-error we don't need registerMock to return all `register` keys
const { getByText } = render(<BackendConfig register={registerMock} />);
expect(getByText("Backend config")).toBeDefined();
expect(getByText("Backend URL")).toBeDefined();
expect(getByText("Supabase URL")).toBeDefined();
expect(getByText("Supabase key")).toBeDefined();
expect(getByText("Keep in local")).toBeDefined();
expect(getByText("Keep in local")).toBeDefined();
expect(registerMock).toHaveBeenCalledWith("backendUrl");
expect(registerMock).toHaveBeenCalledWith("supabaseUrl");
expect(registerMock).toHaveBeenCalledWith("supabaseKey");
expect(registerMock).toHaveBeenCalledWith("backendUrl");
});
});

View File

@ -1,64 +0,0 @@
/* eslint-disable */
"use client";
import { useRouter } from "next/navigation";
import Button from "@/lib/components/ui/Button";
import { useConfig } from "../hooks/useConfig";
import { BackendConfig } from "./BackendConfig/BackendConfig";
import { ModelConfig } from "./ModelConfig";
import { UserAccountSection } from "./UserAccountSection";
export const ConfigForm = (): JSX.Element => {
const {
handleSubmit,
isDirty,
maxTokens,
openAiKey,
saveConfig,
register,
temperature,
model,
resetBrainConfig,
} = useConfig();
const router = useRouter();
const handleDoneClick = () => {
if (isDirty) {
saveConfig();
}
router.back();
};
return (
<form
className="flex flex-col gap-5 py-5 w-full max-w-xl"
onSubmit={handleSubmit(handleDoneClick)}
>
<ModelConfig
register={register}
model={model}
openAiKey={openAiKey}
temperature={temperature}
maxTokens={maxTokens}
/>
<BackendConfig register={register} />
<div className="flex justify-between">
<Button
variant="danger"
className="self-end"
type="button"
onClick={resetBrainConfig}
>
Reset
</Button>
<Button variant="secondary" className="self-end">
Done
</Button>
</div>
<UserAccountSection />
</form>
);
};

View File

@ -1,10 +0,0 @@
export const ConfigTitle = (): JSX.Element => {
return (
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold text-center">Configuration</h1>
<h2 className="opacity-50 text-center">
Here, you can choose your model, set your credentials...
</h2>
</div>
);
};

View File

@ -1,102 +0,0 @@
/* eslint-disable */
"use client";
import { UseFormRegister } from "react-hook-form";
import Field from "@/lib/components/ui/Field";
import {
BrainConfig,
Model,
PaidModels,
anthropicModels,
models,
paidModels,
} from "@/lib/context/BrainConfigProvider/types";
import { defineMaxTokens } from "@/lib/helpers/defineMexTokens";
interface ModelConfigProps {
register: UseFormRegister<BrainConfig>;
model: Model | PaidModels;
openAiKey: string | undefined;
temperature: number;
maxTokens: number;
}
export const ModelConfig = ({
register,
model,
openAiKey,
temperature,
maxTokens,
}: ModelConfigProps): JSX.Element => {
return (
<>
<div className="border-b border-gray-300 mt-8 mb-8">
<p className="text-center text-gray-600 uppercase tracking-wide font-semibold">
Model config
</p>
</div>
<Field
type="password"
placeholder="Open AI Key"
className="w-full"
label="Open AI Key"
{...register("openAiKey")}
/>
<fieldset className="w-full flex flex-col">
<label className="flex-1 text-sm" htmlFor="model">
Model
</label>
<select
id="model"
{...register("model")}
className="px-5 py-2 dark:bg-gray-700 bg-gray-200 rounded-md"
>
{(openAiKey ? paidModels : models).map((model) => (
<option value={model} key={model}>
{model}
</option>
))}
</select>
</fieldset>
{(anthropicModels as readonly string[]).includes(model) && (
<Field
type="text"
placeholder="Anthropic API Key"
className="w-full"
label="Anthropic API Key"
{...register("anthropicKey")}
/>
)}
<fieldset className="w-full flex">
<label className="flex-1" htmlFor="temp">
Temperature: {temperature}
</label>
<input
id="temp"
type="range"
min="0"
max="1"
step="0.01"
value={temperature}
{...register("temperature")}
/>
</fieldset>
<fieldset className="w-full flex">
<label className="flex-1" htmlFor="tokens">
Tokens: {maxTokens}
</label>
<input
type="range"
min="256"
max={defineMaxTokens(model)}
step="32"
value={maxTokens}
{...register("maxTokens")}
/>
</fieldset>
</>
);
};

View File

@ -1,36 +0,0 @@
/* eslint-disable */
"use client";
import Link from "next/link";
import Button from "@/lib/components/ui/Button";
import { useSupabase } from "@/lib/context/SupabaseProvider";
export const UserAccountSection = (): JSX.Element => {
const { session } = useSupabase();
if (session === null) {
return <></>;
}
return (
<>
<div className="border-b border-gray-300 mt-8 mb-8">
<p className="text-center text-gray-600 uppercase tracking-wide font-semibold">
Your Account
</p>
</div>
<div className="flex justify-between items-center w-full">
<span>
Signed In as: <b>{session.user.email}</b>
</span>
<Link className="mt-2" href={"/logout"}>
<Button className="px-3 py-2" variant={"danger"}>
Logout
</Button>
</Link>
{/* TODO: add functionality to change password */}
</div>
</>
);
};

View File

@ -1,3 +0,0 @@
export * from "./ApiKeyConfig/ApiKeyConfig";
export * from "./ConfigForm";
export * from "./ConfigTitle";

View File

@ -1,84 +0,0 @@
/* eslint-disable */
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useBrainConfig } from "@/lib/context/BrainConfigProvider/hooks/useBrainConfig";
import { useToast } from "@/lib/hooks/useToast";
export const useConfig = () => {
const { config, updateConfig, resetConfig } = useBrainConfig();
const { publish } = useToast();
const {
register,
handleSubmit,
watch,
getValues,
reset,
formState: { isDirty },
setError,
} = useForm({
defaultValues: config,
});
const model = watch("model");
const temperature = watch("temperature");
const maxTokens = watch("maxTokens");
const openAiKey = watch("openAiKey");
useEffect(() => {
reset(config);
}, [config, reset]);
const saveConfig = () => {
const values = getValues();
if (!validateConfig()) {
return;
}
updateConfig(values);
publish({
text: "Config saved",
variant: "success",
});
};
const resetBrainConfig = () => {
resetConfig();
publish({
text: "Config reset",
variant: "success",
});
};
const openAiKeyPattern = /^sk-[a-zA-Z0-9]{45,50}$/;
const validateConfig = (): boolean => {
const { openAiKey } = getValues();
const isKeyEmpty = openAiKey === "" || openAiKey === undefined;
if (isKeyEmpty || openAiKeyPattern.test(openAiKey)) {
return true;
}
publish({
text: "Invalid OpenAI Key",
variant: "danger",
});
setError("openAiKey", { type: "pattern", message: "Invalid OpenAI Key" });
return false;
};
return {
handleSubmit,
saveConfig,
maxTokens,
openAiKey,
temperature,
isDirty,
register,
model,
resetBrainConfig,
};
};

View File

@ -1,27 +0,0 @@
/* eslint-disable */
"use client";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { ApiKeyConfig, ConfigForm, ConfigTitle } from "./components";
// TODO: Use states instead of NEXTJS router to open and close modal
const ConfigPage = (): JSX.Element => {
const { session } = useSupabase();
if (session === null) {
redirectToLogin();
}
return (
<main className="w-full flex flex-col">
<section className="w-full outline-none pt-10 flex flex-col gap-5 items-center justify-center p-6">
<ConfigTitle />
<ConfigForm />
<ApiKeyConfig />
</section>
</main>
);
};
export default ConfigPage;

View File

@ -0,0 +1,20 @@
import { ChatConfig } from "@/lib/context/ChatProvider/types";
export const saveChatConfigInLocalStorage = (
chatId: string,
chatConfig: ChatConfig
): void => {
localStorage.setItem(`chat-config-${chatId}`, JSON.stringify(chatConfig));
};
export const getChatConfigFromLocalStorage = (
chatId: string
): ChatConfig | undefined => {
const config = localStorage.getItem(`chat-config-${chatId}`);
if (config === null) {
return undefined;
}
return JSON.parse(config) as ChatConfig;
};

View File

@ -13,7 +13,7 @@ import { useToast } from "@/lib/hooks";
export const useAddBrainModal = () => {
const [isPending, setIsPending] = useState(false);
const { publish } = useToast();
const { createBrain } = useBrainContext();
const { createBrain, setActiveBrain } = useBrainContext();
const { setAsDefaultBrain } = useBrainApi();
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const { config } = useBrainConfig();
@ -56,15 +56,21 @@ export const useAddBrainModal = () => {
temperature,
});
if (setDefault) {
if (createdBrainId === undefined) {
publish({
variant: "danger",
text: "Error occurred while creating a brain",
});
if (createdBrainId === undefined) {
publish({
variant: "danger",
text: "Error occurred while creating a brain",
});
return;
}
return;
}
setActiveBrain({
id: createdBrainId,
name,
});
if (setDefault) {
await setAsDefaultBrain(createdBrainId);
}

View File

@ -1,7 +1,7 @@
import Link from "next/link";
import { FaBrain } from "react-icons/fa";
import { MdSettings } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
export const BrainManagementButton = (): JSX.Element => {
@ -9,10 +9,13 @@ export const BrainManagementButton = (): JSX.Element => {
return (
<Link href={`/brains-management/${currentBrainId ?? ""}`}>
<button type="button" className="flex items-center focus:outline-none">
<MdSettings className="w-6 h-6" color="gray" />
<FaBrain className="w-3 h-3" color="gray" />
</button>
<Button
variant={"tertiary"}
className="focus:outline-none text-2xl"
aria-label="Settings"
>
<MdSettings />
</Button>
</Link>
);
};

View File

@ -1,9 +1,8 @@
"use client";
import Link from "next/link";
import { Dispatch, HTMLAttributes, SetStateAction } from "react";
import { MdPerson, MdSettings } from "react-icons/md";
import { MdPerson } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { cn } from "@/lib/utils";
@ -63,15 +62,6 @@ export const NavItems = ({
<Link aria-label="account" className="" href={"/user"}>
<MdPerson className="text-2xl" />
</Link>
<Link href={"/config"}>
<Button
variant={"tertiary"}
className="focus:outline-none text-2xl"
aria-label="Settings"
>
<MdSettings />
</Button>
</Link>
</>
)}
{!isUserLoggedIn && <AuthButtons />}

View File

@ -1,5 +1,13 @@
import { ChatHistory } from "@/app/chat/[chatId]/types";
import { Model } from "../BrainConfigProvider/types";
export type ChatConfig = {
model?: Model;
temperature?: number;
maxTokens?: number;
};
export type ChatContextProps = {
history: ChatHistory[];
setHistory: (history: ChatHistory[]) => void;