Feat/model config (#223)

* feat(axios): add global manager

* feat: add config page

* feat(axios): add backendUrl overwrite

* feat(brainConfig): add supabase url overwrite

* feat(chat): change model config logic + add more model

* feat: add openai and anthropic api key overwrite

* feat(config): save config in local storage

* feat(config): add reset button

* feat: move vertexai to config page

* ui: add brain config icon
This commit is contained in:
Mamadou DICKO 2023-06-02 17:01:49 +02:00 committed by GitHub
parent 2f6407ef9e
commit 6ff9309082
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 480 additions and 147 deletions

View File

@ -19,7 +19,7 @@ const ChatMessages: FC<ChatMessagesProps> = ({ history }) => {
<div className="overflow-hidden flex flex-col gap-5 scrollbar scroll-smooth">
{history.length === 0 ? (
<div className="text-center opacity-50">
Ask a question, or describe a task.
Ask a question, or describe a task.
</div>
) : (
<AnimatePresence initial={false}>
@ -93,6 +93,6 @@ const ChatMessage = forwardRef(
}
);
ChatMessage.displayName = 'ChatMessage';
ChatMessage.displayName = "ChatMessage";
export default ChatMessages;

View File

@ -1,11 +1,12 @@
"use client";
import axios from "axios";
import { useBrainConfig } from "@/lib/context/BrainConfigProvider/hooks/useBrainConfig";
import { useAxios } from "@/lib/useAxios";
import Link from "next/link";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { MdMic, MdMicOff, MdSettings } from "react-icons/md";
import Button from "../components/ui/Button";
import Card from "../components/ui/Card";
import Modal from "../components/ui/Modal";
import PageHeading from "../components/ui/PageHeading";
import { useSupabase } from "../supabase-provider";
import ChatMessages from "./ChatMessages";
@ -14,12 +15,13 @@ import { isSpeechRecognitionSupported } from "./helpers";
export default function ChatPage() {
const [question, setQuestion] = useState("");
const [history, setHistory] = useState<Array<[string, string]>>([]);
const [model, setModel] = useState("gpt-3.5-turbo");
const [temperature, setTemperature] = useState(0);
const [maxTokens, setMaxTokens] = useState(500);
const [isPending, setIsPending] = useState(false);
const [isListening, setIsListening] = useState(false);
const { session } = useSupabase();
const { axiosInstance } = useAxios();
const {
config: { maxTokens, model, temperature },
} = useBrainConfig();
if (session === null) {
redirect("/login");
}
@ -70,21 +72,14 @@ export default function ChatPage() {
setHistory((hist) => [...hist, ["user", question]]);
setIsPending(true);
setIsListening(false);
const response = await axios.post(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/`,
{
model,
question,
history,
temperature,
max_tokens: maxTokens,
},
{
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}
);
const response = await axiosInstance.post(`/chat/`, {
model,
question,
history,
temperature,
max_tokens: maxTokens,
});
setHistory(response.data.history);
setQuestion("");
setIsPending(false);
@ -137,65 +132,11 @@ export default function ChatPage() {
<MdMic className="text-2xl" />
)}
</Button>
{/* Settings Button */}
<Modal
Trigger={
<Button className="px-3" variant={"tertiary"}>
<MdSettings className="text-2xl" />
</Button>
}
title="Settings"
desc="Modify your brain"
>
<form className="flex flex-col gap-5 py-5">
<fieldset className="w-full flex">
<label className="flex-1" htmlFor="model">
Model:
</label>
<select
name="model"
id="model"
value={model}
className="px-5 py-2 dark:bg-gray-700 bg-gray-200 rounded-md"
onChange={(e) => setModel(e.target.value)}
>
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
<option value="gpt-4">gpt-4</option>
<option value="vertexai">vertexai</option>
</select>
</fieldset>
<fieldset className="w-full flex">
<label className="flex-1" htmlFor="temp">
Temperature: {temperature}
</label>
<input
name="temp"
id="temp"
type="range"
min="0"
max="1"
step="0.01"
value={temperature}
onChange={(e) => setTemperature(+e.target.value)}
/>
</fieldset>
<fieldset className="w-full flex">
<label className="flex-1" htmlFor="tokens">
Tokens: {maxTokens}
</label>
<input
name="tokens"
id="tokens"
type="range"
min="256"
max="3000"
step="1"
value={maxTokens}
onChange={(e) => setMaxTokens(+e.target.value)}
/>
</fieldset>
</form>
</Modal>
<Link href={"/config"}>
<Button className="px-3" variant={"tertiary"}>
<MdSettings className="text-2xl" />
</Button>
</Link>
</form>
</Card>
</Card>

View File

@ -3,6 +3,7 @@ import { useSupabase } from "@/app/supabase-provider";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { Dispatch, FC, HTMLAttributes, ReactNode, SetStateAction } from "react";
import { MdSettings } from "react-icons/md";
import Button from "../ui/Button";
import DarkModeToggle from "./DarkModeToggle";
@ -13,7 +14,8 @@ interface NavItemsProps extends HTMLAttributes<HTMLUListElement> {
const NavItems: FC<NavItemsProps> = ({ className, setOpen, ...props }) => {
const { session } = useSupabase();
const isUserLoggedIn = session?.user !== undefined;
const isLocal = (process.env.NEXT_PUBLIC_ENV === "local") || (session !== null);
const isLocal = process.env.NEXT_PUBLIC_ENV === "local";
return (
<ul
className={cn(
@ -22,7 +24,7 @@ const NavItems: FC<NavItemsProps> = ({ className, setOpen, ...props }) => {
)}
{...props}
>
{isLocal ? (
{isLocal || isUserLoggedIn ? (
<>
<NavLink setOpen={setOpen} to="/upload">
Upload
@ -46,11 +48,21 @@ const NavItems: FC<NavItemsProps> = ({ className, setOpen, ...props }) => {
)}
<div className="flex sm:flex-1 sm:justify-end flex-col items-center justify-center sm:flex-row gap-5 sm:gap-2">
{isUserLoggedIn && (
<Link href={"/logout"}>
<Button variant={"secondary"}>Logout</Button>
</Link>
<>
<Link href={"/logout"}>
<Button variant={"secondary"}>Logout</Button>
</Link>
<Link href={"/config"}>
<Button
variant={"tertiary"}
className="focus:outline-none text-2xl"
>
<MdSettings />
</Button>
</Link>
</>
)}
{!isLocal && (
{!isLocal && !isUserLoggedIn && (
<Link href={"https://try-quivr.streamlit.app"}>
<Button variant={"secondary"}>Try Demo</Button>
</Link>

View File

@ -0,0 +1,55 @@
import { useToast } from "@/lib/hooks/useToast";
import { useForm } from "react-hook-form";
import { useBrainConfig } from "@/lib/context/BrainConfigProvider/hooks/useBrainConfig";
import { useEffect } from "react";
export const useConfig = () => {
const { config, updateConfig, resetConfig } = useBrainConfig();
const { publish } = useToast();
const {
register,
handleSubmit,
watch,
getValues,
reset,
formState: { isDirty },
} = useForm({
defaultValues: config,
});
const model = watch("model");
const temperature = watch("temperature");
const maxTokens = watch("maxTokens");
useEffect(() => {
reset(config);
}, [config, reset]);
const saveConfig = () => {
updateConfig(getValues());
publish({
text: "Config saved",
variant: "success",
});
};
const resetBrainConfig = () => {
resetConfig();
publish({
text: "Config reset",
variant: "success",
});
};
return {
handleSubmit,
saveConfig,
maxTokens,
temperature,
isDirty,
register,
model,
resetBrainConfig,
};
};

View File

@ -0,0 +1,164 @@
"use client";
import { redirect } from "next/navigation";
import {
anthropicModels,
models,
} from "@/lib/context/BrainConfigProvider/types";
import Button from "../components/ui/Button";
import Field from "../components/ui/Field";
import { useSupabase } from "../supabase-provider";
import { useConfig } from "./hooks/useConfig";
export default function ExplorePage() {
const { session } = useSupabase();
const {
handleSubmit,
isDirty,
maxTokens,
saveConfig,
register,
temperature,
model,
resetBrainConfig,
} = useConfig();
if (session === null) {
redirect("/login");
}
return (
<main className="min-h-screen w-full flex flex-col">
<section className="w-full outline-none pt-32 flex flex-col gap-5 items-center justify-center p-6">
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold text-center">Configuration</h1>
<h2 className="opacity-50">
Here, you can choose your model, set your credentials...
</h2>
</div>
<form
className="flex flex-col gap-5 py-5 w-1/2"
onSubmit={handleSubmit(saveConfig)}
>
<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="text"
placeholder="Open AI Key"
className="w-full"
label="Open AI Key"
{...register("openAiKey")}
/>
<fieldset className="w-full flex 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((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="3000"
step="1"
value={maxTokens}
{...register("maxTokens")}
/>
</fieldset>
<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>
<div className="flex justify-between">
<Button
variant="danger"
className="self-end"
type="button"
onClick={resetBrainConfig}
>
Reset
</Button>
<Button
disabled={!isDirty}
variant="secondary"
className="self-end"
>
Done
</Button>
</div>
</form>
</section>
</main>
);
}

View File

@ -1,27 +1,35 @@
import axios from "axios";
import { useAxios } from "@/lib/useAxios";
import { useEffect, useState } from "react";
import { useSupabase } from "../../supabase-provider";
interface DocumentDataProps {
documentName: string;
}
const DocumentData = async ({ documentName }: DocumentDataProps) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DocumentDetails = any;
//TODO: review this component logic, types and purposes
const DocumentData = ({ documentName }: DocumentDataProps): JSX.Element => {
const { session } = useSupabase();
const { axiosInstance } = useAxios();
const [documents, setDocuments] = useState<DocumentDetails[]>([]);
if (!session) {
throw new Error("User session not found");
}
const res = await axios.get(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore/${documentName}`,
{
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}
);
// TODO: review the logic of this part and try to use unknown instead of any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const documents = res.data.documents as any[];
useEffect(() => {
const fetchDocuments = async () => {
const res = await axiosInstance.get<{ documents: DocumentDetails[] }>(
`/explore/${documentName}`
);
setDocuments(res.data.documents);
};
fetchDocuments();
}, [axiosInstance, documentName]);
return (
<div className="prose">
<p>No. of documents: {documents.length}</p>

View File

@ -1,13 +1,11 @@
"use client";
import Spinner from "@/app/components/ui/Spinner";
import { useSupabase } from "@/app/supabase-provider";
import { useToast } from "@/lib/hooks/useToast";
import axios from "axios";
import { useAxios } from "@/lib/useAxios";
import {
Dispatch,
RefObject,
SetStateAction,
Suspense,
forwardRef,
useState,
} from "react";
@ -27,6 +25,8 @@ const DocumentItem = forwardRef(
const [isDeleting, setIsDeleting] = useState(false);
const { publish } = useToast();
const { session } = useSupabase();
const { axiosInstance } = useAxios();
if (!session) {
throw new Error("User session not found");
}
@ -34,14 +34,7 @@ const DocumentItem = forwardRef(
const deleteDocument = async (name: string) => {
setIsDeleting(true);
try {
await axios.delete(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore/${name}`,
{
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}
);
await axiosInstance.delete(`/explore/${name}`);
setDocuments((docs) => docs.filter((doc) => doc.name !== name)); // Optimistic update
publish({ variant: "success", text: `${name} deleted.` });
} catch (error) {
@ -61,19 +54,14 @@ const DocumentItem = forwardRef(
>
<p className="text-lg leading-tight max-w-sm">{document.name}</p>
<div className="flex gap-2 self-end">
{/* VIEW MODAL */}
<Modal
title={document.name}
desc={""}
Trigger={<Button className="">View</Button>}
>
<Suspense fallback={<Spinner />}>
{/* @ts-expect-error TODO: check if DocumentData component can be sync */}
<DocumentData documentName={document.name} />
</Suspense>
<DocumentData documentName={document.name} />
</Modal>
{/* DELETE MODAL */}
<Modal
title={"Confirm"}
desc={`Do you really want to delete?`}

View File

@ -1,5 +1,5 @@
"use client";
import axios from "axios";
import { useAxios } from "@/lib/useAxios";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import { redirect } from "next/navigation";
@ -14,6 +14,8 @@ export default function ExplorePage() {
const [documents, setDocuments] = useState<Document[]>([]);
const [isPending, setIsPending] = useState(true);
const { session } = useSupabase();
const { axiosInstance } = useAxios();
if (session === null) {
redirect("/login");
}
@ -25,13 +27,8 @@ export default function ExplorePage() {
console.log(
`Fetching documents from ${process.env.NEXT_PUBLIC_BACKEND_URL}/explore`
);
const response = await axios.get<{ documents: Document[] }>(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore`,
{
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}
const response = await axiosInstance.get<{ documents: Document[] }>(
"/explore"
);
setDocuments(response.data.documents);
} catch (error) {

View File

@ -2,6 +2,7 @@ import { createServerComponentSupabaseClient } from "@supabase/auth-helpers-next
import { Analytics } from "@vercel/analytics/react";
import { Inter } from "next/font/google";
import { cookies, headers } from "next/headers";
import { BrainConfigProvider } from "../lib/context/BrainConfigProvider/brain-config-provider";
import NavBar from "./components/NavBar";
import { ToastProvider } from "./components/ui/Toast";
import "./globals.css";
@ -36,8 +37,10 @@ export default async function RootLayout({
>
<ToastProvider>
<SupabaseProvider session={session}>
<NavBar />
{children}
<BrainConfigProvider>
<NavBar />
{children}
</BrainConfigProvider>
</SupabaseProvider>
</ToastProvider>
<Analytics />

View File

@ -1,6 +1,6 @@
import { useSupabase } from "@/app/supabase-provider";
import { useToast } from "@/lib/hooks/useToast";
import axios from "axios";
import { useAxios } from "@/lib/useAxios";
import { redirect } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { isValidUrl } from "../helpers/isValidUrl";
@ -10,6 +10,8 @@ export const useCrawler = () => {
const urlInputRef = useRef<HTMLInputElement | null>(null);
const { session } = useSupabase();
const { publish } = useToast();
const { axiosInstance } = useAxios();
if (session === null) {
redirect("/login");
}
@ -39,15 +41,7 @@ export const useCrawler = () => {
};
try {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/crawl`,
config,
{
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}
);
const response = await axiosInstance.post(`/crawl`, config);
publish({
variant: response.data.type,

View File

@ -1,6 +1,6 @@
import { useSupabase } from "@/app/supabase-provider";
import { useToast } from "@/lib/hooks/useToast";
import axios from "axios";
import { useAxios } from "@/lib/useAxios";
import { redirect } from "next/navigation";
import { useCallback, useState } from "react";
import { FileRejection, useDropzone } from "react-dropzone";
@ -12,6 +12,8 @@ export const useFileUploader = () => {
const [pendingFileIndex, setPendingFileIndex] = useState<number>(0);
const { session } = useSupabase();
const { axiosInstance } = useAxios();
if (session === null) {
redirect("/login");
}
@ -21,15 +23,7 @@ export const useFileUploader = () => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/upload`,
formData,
{
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}
);
const response = await axiosInstance.post(`/upload`, formData);
publish({
variant: response.data.type,

View File

@ -0,0 +1,62 @@
"use client";
import { setEmptyStringsUndefined } from "@/lib/helpers/setEmptyStringsUndefined";
import { createContext, useEffect, useState } from "react";
import {
getBrainConfigFromLocalStorage,
saveBrainConfigInLocalStorage,
} from "./helpers/brainConfigLocalStorage";
import { BrainConfig, ConfigContext } from "./types";
export const BrainConfigContext = createContext<ConfigContext | undefined>(
undefined
);
const defaultBrainConfig: BrainConfig = {
model: "gpt-3.5-turbo",
temperature: 0,
maxTokens: 500,
keepLocal: true,
anthropicKey: undefined,
backendUrl: undefined,
openAiKey: undefined,
supabaseKey: undefined,
supabaseUrl: undefined,
};
export const BrainConfigProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [brainConfig, setBrainConfig] =
useState<BrainConfig>(defaultBrainConfig);
const updateConfig = (newConfig: Partial<BrainConfig>) => {
setBrainConfig((config) => {
const updatedConfig: BrainConfig = {
...config,
...setEmptyStringsUndefined(newConfig),
};
saveBrainConfigInLocalStorage(updatedConfig);
return updatedConfig;
});
};
const resetConfig = () => {
updateConfig(defaultBrainConfig);
};
useEffect(() => {
setBrainConfig(getBrainConfigFromLocalStorage() ?? defaultBrainConfig);
}, []);
return (
<BrainConfigContext.Provider
value={{ config: brainConfig, updateConfig, resetConfig }}
>
{children}
</BrainConfigContext.Provider>
);
};

View File

@ -0,0 +1,17 @@
import { BrainConfig } from "../types";
const BRAIN_CONFIG_LOCAL_STORAGE_KEY = "userBrainConfig";
export const saveBrainConfigInLocalStorage = (updatedConfig: BrainConfig) => {
localStorage.setItem(
BRAIN_CONFIG_LOCAL_STORAGE_KEY,
JSON.stringify(updatedConfig)
);
};
export const getBrainConfigFromLocalStorage = (): BrainConfig | undefined => {
const persistedBrainConfig = localStorage.getItem(
BRAIN_CONFIG_LOCAL_STORAGE_KEY
);
if (persistedBrainConfig === null) return;
return JSON.parse(persistedBrainConfig);
};

View File

@ -0,0 +1,12 @@
import { useContext } from "react";
import { BrainConfigContext } from "../brain-config-provider";
export const useBrainConfig = () => {
const context = useContext(BrainConfigContext);
if (context === undefined) {
throw new Error("useConfig must be used inside SupabaseProvider");
}
return context;
};

View File

@ -0,0 +1,38 @@
export type BrainConfig = {
model: Model;
temperature: number;
maxTokens: number;
keepLocal: boolean;
backendUrl?: string;
openAiKey?: string;
anthropicKey?: string;
supabaseUrl?: string;
supabaseKey?: string;
};
type OptionalConfig = { [K in keyof BrainConfig]?: BrainConfig[K] | undefined };
export type ConfigContext = {
config: BrainConfig;
updateConfig: (config: OptionalConfig) => void;
resetConfig: () => void;
};
export const openAiModels = ["gpt-3.5-turbo", "gpt-4"] as const;
export const anthropicModels = [
"claude-v1",
"claude-v1.3",
"claude-instant-v1-100k",
"claude-instant-v1.1-100k",
] as const;
export const googleModels = ["vertexai"] as const;
export const models = [
...openAiModels,
...anthropicModels,
...googleModels,
] as const;
export type Model = (typeof models)[number];

View File

@ -0,0 +1,11 @@
export const setEmptyStringsUndefined = (
obj: Record<string, unknown>
): Record<string, unknown> => {
Object.keys(obj).forEach((key) => {
if (obj[key] === "") {
obj[key] = undefined;
}
});
return obj;
};

31
frontend/lib/useAxios.ts Normal file
View File

@ -0,0 +1,31 @@
import { useSupabase } from "@/app/supabase-provider";
import axios from "axios";
import { useBrainConfig } from "./context/BrainConfigProvider/hooks/useBrainConfig";
const axiosInstance = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_BACKEND_URL}`,
});
export const useAxios = () => {
const { session } = useSupabase();
const {
config: { backendUrl },
} = useBrainConfig();
axiosInstance.interceptors.request.clear();
axiosInstance.interceptors.request.use(
async (config) => {
config.headers["Authorization"] = "Bearer " + session?.access_token;
config.baseURL = backendUrl ?? config.baseURL;
return config;
},
(error) => {
console.error({ error });
void Promise.reject(error);
}
);
return {
axiosInstance,
};
};

View File

@ -37,6 +37,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.44.3",
"react-markdown": "^8.0.7",
"rehype-highlight": "^6.0.0",
"tailwind-merge": "^1.12.0",

View File

@ -3199,6 +3199,11 @@ react-dropzone@^14.2.3:
file-selector "^0.6.0"
prop-types "^15.8.1"
react-hook-form@^7.44.3:
version "7.44.3"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.44.3.tgz#a99e560c6ef2b668db1daaebc4f98267331b6828"
integrity sha512-/tHId6p2ViAka1wECMw8FEPn/oz/w226zehHrJyQ1oIzCBNMIJCaj6ZkQcv+MjDxYh9MWR7RQic7Qqwe4a5nkw==
react-icons@^4.8.0:
version "4.8.0"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.8.0.tgz#621e900caa23b912f737e41be57f27f6b2bff445"