[FEAT] New Workspace Settings Layout (#718)

* WIP new settings layout

* add suggested messages to general & appearance and clean up/make more user friendly

* lazy load workspace settings pages

* css fix on X button for document picker where button is barely clickable

* remove additional workspace settings page

* fix thread selection action when on thread

* refactor inputs into sub-components
remove unused paths

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-02-14 15:29:49 -08:00 committed by GitHub
parent dbb865f6f3
commit 858b2fcedb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 413 additions and 775 deletions

View File

@ -57,6 +57,10 @@ export default function App() {
<Routes>
<Route path="/" element={<PrivateRoute Component={Main} />} />
<Route path="/login" element={<Login />} />
<Route
path="/workspace/:slug/settings/:tab"
element={<PrivateRoute Component={WorkspaceSettings} />}
/>
<Route
path="/workspace/:slug"
element={<PrivateRoute Component={WorkspaceChat} />}
@ -68,10 +72,6 @@ export default function App() {
<Route path="/accept-invite/:code" element={<InvitePage />} />
{/* Admin */}
<Route
path="/workspace/:slug/settings"
element={<PrivateRoute Component={WorkspaceSettings} />}
/>
<Route
path="/settings/llm-preference"
element={<AdminRoute Component={GeneralLLMPreference} />}

View File

@ -195,7 +195,7 @@ export default function DocumentSettings({
};
return (
<div className="flex gap-x-6 justify-center">
<div className="flex gap-x-6 justify-center -mt-6 z-10 relative">
<Directory
files={availableDocs}
loading={loading}

View File

@ -1,120 +0,0 @@
import useGetProviderModels, {
DISABLED_PROVIDERS,
} from "./useGetProviderModels";
export default function ChatModelSelection({
settings,
workspace,
setHasChanges,
}) {
const { defaultModels, customModels, loading } = useGetProviderModels(
settings?.LLMProvider
);
if (DISABLED_PROVIDERS.includes(settings?.LLMProvider)) return null;
if (loading) {
return (
<div>
<div className="flex flex-col">
<label
htmlFor="name"
className="block text-sm font-medium text-white"
>
Chat model
</label>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
The specific chat model that will be used for this workspace. If
empty, will use the system LLM preference.
</p>
</div>
<select
name="chatModel"
required={true}
disabled={true}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
>
<option disabled={true} selected={true}>
-- waiting for models --
</option>
</select>
</div>
);
}
return (
<div>
<div className="flex flex-col">
<label htmlFor="name" className="block text-sm font-medium text-white">
Chat model{" "}
<span className="font-normal">({settings?.LLMProvider})</span>
</label>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
The specific chat model that will be used for this workspace. If
empty, will use the system LLM preference.
</p>
</div>
<select
name="chatModel"
required={true}
onChange={() => {
setHasChanges(true);
}}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
>
<option disabled={true} selected={workspace?.chatModel === null}>
System default
</option>
{defaultModels.length > 0 && (
<optgroup label="General models">
{defaultModels.map((model) => {
return (
<option
key={model}
value={model}
selected={workspace?.chatModel === model}
>
{model}
</option>
);
})}
</optgroup>
)}
{Array.isArray(customModels) && customModels.length > 0 && (
<optgroup label="Custom models">
{customModels.map((model) => {
return (
<option
key={model.id}
value={model.id}
selected={workspace?.chatModel === model.id}
>
{model.id}
</option>
);
})}
</optgroup>
)}
{/* For providers like TogetherAi where we partition model by creator entity. */}
{!Array.isArray(customModels) &&
Object.keys(customModels).length > 0 && (
<>
{Object.entries(customModels).map(([organization, models]) => (
<optgroup key={organization} label={organization}>
{models.map((model) => (
<option
key={model.id}
value={model.id}
selected={workspace?.chatModel === model.id}
>
{model.name}
</option>
))}
</optgroup>
))}
</>
)}
</select>
</div>
);
}

View File

@ -1,387 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import Workspace from "../../../../models/workspace";
import paths from "../../../../utils/paths";
import { chatPrompt } from "../../../../utils/chat";
import System from "../../../../models/system";
import PreLoader from "../../../Preloader";
import { useParams } from "react-router-dom";
import showToast from "../../../../utils/toast";
import ChatModelPreference from "./ChatModelPreference";
import { Link } from "react-router-dom";
// Ensure that a type is correct before sending the body
// to the backend.
function castToType(key, value) {
const definitions = {
openAiTemp: {
cast: (value) => Number(value),
},
openAiHistory: {
cast: (value) => Number(value),
},
similarityThreshold: {
cast: (value) => parseFloat(value),
},
topN: {
cast: (value) => Number(value),
},
};
if (!definitions.hasOwnProperty(key)) return value;
return definitions[key].cast(value);
}
function recommendedSettings(provider = null) {
switch (provider) {
case "mistral":
return { temp: 0 };
default:
return { temp: 0.7 };
}
}
export default function WorkspaceSettings({ active, workspace, settings }) {
const { slug } = useParams();
const formEl = useRef(null);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [deleting, setDeleting] = useState(false);
const defaults = recommendedSettings(settings?.LLMProvider);
const handleUpdate = async (e) => {
setSaving(true);
e.preventDefault();
const data = {};
const form = new FormData(formEl.current);
for (var [key, value] of form.entries()) data[key] = castToType(key, value);
const { workspace: updatedWorkspace, message } = await Workspace.update(
workspace.slug,
data
);
if (!!updatedWorkspace) {
showToast("Workspace updated!", "success", { clear: true });
} else {
showToast(`Error: ${message}`, "error", { clear: true });
}
setSaving(false);
setHasChanges(false);
};
const deleteWorkspace = async () => {
if (
!window.confirm(
`You are about to delete your entire ${workspace.name} workspace. This will remove all vector embeddings on your vector database.\n\nThe original source files will remain untouched. This action is irreversible.`
)
)
return false;
setDeleting(true);
const success = await Workspace.delete(workspace.slug);
if (!success) {
showToast("Workspace could not be deleted!", "error", { clear: true });
setDeleting(false);
return;
}
workspace.slug === slug
? (window.location = paths.home())
: window.location.reload();
};
return (
<form ref={formEl} onSubmit={handleUpdate}>
<div className="-mt-12 px-12 pb-6 flex flex-col h-full w-full max-h-[80vh] overflow-y-scroll">
<div className="flex flex-col gap-y-1 min-w-[900px]">
<div className="text-white text-opacity-60 text-sm font-bold uppercase py-6 border-b-2 border-white/10">
Workspace Settings
</div>
<div className="flex flex-row w-full py-6 border-b-2 border-white/10">
<div className="w-1/2">
<h3 className="text-white text-sm font-semibold">
Vector database identifier
</h3>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
{" "}
</p>
<p className="text-white text-opacity-60 text-sm font-medium">
{workspace?.slug}
</p>
</div>
<div className="w-1/2">
<h3 className="text-white text-sm font-semibold">
Number of vectors
</h3>
<p className="text-white text-opacity-60 text-xs font-medium my-[2px]">
Total number of vectors in your vector database.
</p>
<VectorCount reload={active} workspace={workspace} />
</div>
</div>
</div>
<div className="flex flex-col gap-y-1 w-full mt-7">
<div className="flex">
<div className="flex flex-col gap-y-4 w-1/2">
<div className="w-3/4 flex flex-col gap-y-4">
<ChatModelPreference
settings={settings}
workspace={workspace}
setHasChanges={setHasChanges}
/>
<div>
<div className="flex flex-col">
<label
htmlFor="name"
className="block text-sm font-medium text-white"
>
Workspace Name
</label>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
This will only change the display name of your workspace.
</p>
</div>
<input
name="name"
type="text"
minLength={2}
maxLength={80}
defaultValue={workspace?.name}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
placeholder="My Workspace"
required={true}
autoComplete="off"
onChange={() => setHasChanges(true)}
/>
</div>
<div>
<div className="flex flex-col">
<label
htmlFor="name"
className="block text-sm font-medium text-white"
>
LLM Temperature
</label>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
This setting controls how "random" or dynamic your chat
responses will be.
<br />
The higher the number (1.0 maximum) the more random and
incoherent.
<br />
<i>Recommended: {defaults.temp}</i>
</p>
</div>
<input
name="openAiTemp"
type="number"
min={0.0}
max={1.0}
step={0.1}
onWheel={(e) => e.target.blur()}
defaultValue={workspace?.openAiTemp ?? defaults.temp}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
placeholder="0.7"
required={true}
autoComplete="off"
onChange={() => setHasChanges(true)}
/>
</div>
<div>
<div className="flex flex-col gap-y-1 mb-4">
<label
htmlFor="name"
className="block mb-2 text-sm font-medium text-white"
>
Chat History
</label>
<p className="text-white text-opacity-60 text-xs font-medium">
The number of previous chats that will be included in the
response's short-term memory.
<i>Recommend 20. </i>
Anything more than 45 is likely to lead to continuous chat
failures depending on message size.
</p>
</div>
<input
name="openAiHistory"
type="number"
min={1}
max={45}
step={1}
onWheel={(e) => e.target.blur()}
defaultValue={workspace?.openAiHistory ?? 20}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
placeholder="20"
required={true}
autoComplete="off"
onChange={() => setHasChanges(true)}
/>
</div>
</div>
</div>
<div className="w-1/2">
<div className="w-3/4">
<div className="flex flex-col">
<label
htmlFor="name"
className="block text-sm font-medium text-white"
>
Prompt
</label>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
The prompt that will be used on this workspace. Define the
context and instructions for the AI to generate a response.
You should to provide a carefully crafted prompt so the AI
can generate a relevant and accurate response.
</p>
</div>
<textarea
name="openAiPrompt"
rows={5}
defaultValue={chatPrompt(workspace)}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
placeholder="Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed."
required={true}
wrap="soft"
autoComplete="off"
onChange={() => setHasChanges(true)}
/>
<div className="mt-4">
<div className="flex flex-col">
<label
htmlFor="name"
className="block text-sm font-medium text-white"
>
Max Context Snippets
</label>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
This setting controls the maximum amount of context
snippets the will be sent to the LLM for per chat or
query.
<br />
<i>Recommended: 4</i>
</p>
</div>
<input
name="topN"
type="number"
min={1}
max={12}
step={1}
onWheel={(e) => e.target.blur()}
defaultValue={workspace?.topN ?? 4}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
placeholder="4"
required={true}
autoComplete="off"
onChange={() => setHasChanges(true)}
/>
</div>
<div className="mt-4">
<div className="flex flex-col">
<label
htmlFor="name"
className="block text-sm font-medium text-white"
>
Document similarity threshold
</label>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
The minimum similarity score required for a source to be
considered related to the chat. The higher the number, the
more similar the source must be to the chat.
</p>
</div>
<select
name="similarityThreshold"
defaultValue={workspace?.similarityThreshold ?? 0.25}
className="bg-zinc-900 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
onChange={() => setHasChanges(true)}
required={true}
>
<option value={0.0}>No restriction</option>
<option value={0.25}>
Low (similarity score &ge; .25)
</option>
<option value={0.5}>
Medium (similarity score &ge; .50)
</option>
<option value={0.75}>
High (similarity score &ge; .75)
</option>
</select>
</div>
<div className="mt-4 w-full flex justify-start">
<Link to={paths.workspace.additionalSettings(workspace.slug)}>
<a className="underline text-white/60 text-sm font-medium hover:text-sky-600">
View additional settings
</a>
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-2 md:p-6 space-x-2 border-t rounded-b border-gray-600">
<DeleteWorkspace
deleting={deleting}
workspace={workspace}
onClick={deleteWorkspace}
/>
{hasChanges && (
<button
type="submit"
className="transition-all duration-300 border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
>
{saving ? "Updating..." : "Update workspace"}
</button>
)}
</div>
</form>
);
}
function DeleteWorkspace({ deleting, workspace, onClick }) {
const [canDelete, setCanDelete] = useState(false);
useEffect(() => {
async function fetchKeys() {
const canDelete = await System.getCanDeleteWorkspaces();
setCanDelete(canDelete);
}
fetchKeys();
}, [workspace?.slug]);
if (!canDelete) return null;
return (
<button
disabled={deleting}
onClick={onClick}
type="button"
className="transition-all duration-300 border border-transparent rounded-lg whitespace-nowrap text-sm px-5 py-2.5 focus:z-10 bg-transparent text-white hover:text-white hover:bg-red-600 disabled:bg-red-600 disabled:text-red-200 disabled:animate-pulse"
>
{deleting ? "Deleting Workspace..." : "Delete Workspace"}
</button>
);
}
function VectorCount({ reload, workspace }) {
const [totalVectors, setTotalVectors] = useState(null);
useEffect(() => {
async function fetchVectorCount() {
const totalVectors = await System.totalIndexes(workspace.slug);
setTotalVectors(totalVectors);
}
fetchVectorCount();
}, [workspace?.slug, reload]);
if (totalVectors === null) return <PreLoader size="4" />;
return (
<p className="text-white text-opacity-60 text-sm font-medium">
{totalVectors}
</p>
);
}

View File

@ -1,18 +1,15 @@
import React, { useState, useEffect, lazy, memo } from "react";
import React, { useState, useEffect, memo } from "react";
import { X } from "@phosphor-icons/react";
import { useParams } from "react-router-dom";
import Workspace from "../../../models/workspace";
import System from "../../../models/system";
import { isMobile } from "react-device-detect";
import useUser from "../../../hooks/useUser";
const DocumentSettings = lazy(() => import("./Documents"));
const WorkspaceSettings = lazy(() => import("./Settings"));
import DocumentSettings from "./Documents";
const noop = () => {};
const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => {
const { slug } = useParams();
const [selectedTab, setSelectedTab] = useState("documents");
const [workspace, setWorkspace] = useState(null);
const [fileTypes, setFileTypes] = useState(null);
const [settings, setSettings] = useState({});
@ -72,31 +69,7 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => {
<div className="backdrop h-full w-full absolute top-0 z-10" />
<div className={`absolute max-h-full w-fit transition duration-300 z-20`}>
<div className="relative bg-main-gradient rounded-[12px] shadow border-2 border-slate-300/10">
<div className="absolute top-[-18px] left-1/2 transform -translate-x-1/2 bg-sidebar-button p-1 rounded-xl shadow border-2 border-slate-300/10">
<div className="flex gap-x-1">
<button
onClick={() => setSelectedTab("documents")}
className={`px-4 py-2 rounded-[8px] font-semibold text-white hover:bg-switch-selected hover:bg-opacity-60 ${
selectedTab === "documents"
? "bg-switch-selected shadow-md"
: "bg-sidebar-button"
}`}
>
Documents
</button>
<button
onClick={() => setSelectedTab("settings")}
className={`px-4 py-2 rounded-[8px] font-semibold text-white hover:bg-switch-selected hover:bg-opacity-60 ${
selectedTab === "settings"
? "bg-switch-selected shadow-md"
: "bg-sidebar-button"
}`}
>
Settings
</button>
</div>
</div>
<div className="flex items-start justify-between p-2 rounded-t border-gray-500/50">
<div className="flex items-start justify-between p-2 rounded-t border-gray-500/50 z-40 relative">
<button
onClick={hideModal}
type="button"
@ -105,20 +78,11 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => {
<X className="text-gray-300 text-lg" />
</button>
</div>
<div className={selectedTab === "documents" ? "" : "hidden"}>
<DocumentSettings
workspace={workspace}
fileTypes={fileTypes}
systemSettings={settings}
/>
</div>
<div className={selectedTab === "settings" ? "" : "hidden"}>
<WorkspaceSettings
active={selectedTab === "settings"} // To force reload live sub-components like VectorCount
workspace={workspace}
settings={settings}
/>
</div>
<DocumentSettings
workspace={workspace}
fileTypes={fileTypes}
systemSettings={settings}
/>
</div>
</div>
</div>

View File

@ -16,11 +16,10 @@ export default function ThreadItem({
onRemove,
hasNext,
}) {
const optionsContainer = useRef(null);
const { slug } = useParams();
const optionsContainer = useRef(null);
const [showOptions, setShowOptions] = useState(false);
const [name, setName] = useState(thread.name);
const linkTo = !thread.slug
? paths.workspace.chat(slug)
: paths.workspace.thread(slug, thread.slug);
@ -61,7 +60,10 @@ export default function ThreadItem({
</p>
</a>
) : (
<a href={isActive ? "#" : linkTo} className="w-full">
<a
href={window.location.pathname === linkTo ? "#" : linkTo}
className="w-full"
>
<p
className={`text-left text-sm ${
isActive ? "font-medium text-white" : "text-slate-400"

View File

@ -7,18 +7,20 @@ import ManageWorkspace, {
} from "../../Modals/MangeWorkspace";
import paths from "@/utils/paths";
import { useParams } from "react-router-dom";
import { GearSix, SquaresFour } from "@phosphor-icons/react";
import { GearSix, SquaresFour, UploadSimple } from "@phosphor-icons/react";
import truncate from "truncate";
import useUser from "@/hooks/useUser";
import ThreadContainer from "./ThreadContainer";
import { Link } from "react-router-dom";
export default function ActiveWorkspaces() {
const { slug } = useParams();
const [loading, setLoading] = useState(true);
const [settingHover, setSettingHover] = useState({});
const [workspaces, setWorkspaces] = useState([]);
const [selectedWs, setSelectedWs] = useState(null);
const [hoverStates, setHoverStates] = useState({});
const [gearHover, setGearHover] = useState({});
const [uploadHover, setUploadHover] = useState({});
const { showing, showModal, hideModal } = useManageWorkspaceModal();
const { user } = useUser();
@ -30,7 +32,6 @@ export default function ActiveWorkspaces() {
}
getWorkspaces();
}, []);
const handleMouseEnter = useCallback((workspaceId) => {
setHoverStates((prev) => ({ ...prev, [workspaceId]: true }));
}, []);
@ -38,13 +39,20 @@ export default function ActiveWorkspaces() {
const handleMouseLeave = useCallback((workspaceId) => {
setHoverStates((prev) => ({ ...prev, [workspaceId]: false }));
}, []);
const handleGearMouseEnter = useCallback((workspaceId) => {
setSettingHover((prev) => ({ ...prev, [workspaceId]: true }));
setGearHover((prev) => ({ ...prev, [workspaceId]: true }));
}, []);
const handleGearMouseLeave = useCallback((workspaceId) => {
setSettingHover((prev) => ({ ...prev, [workspaceId]: false }));
setGearHover((prev) => ({ ...prev, [workspaceId]: false }));
}, []);
const handleUploadMouseEnter = useCallback((workspaceId) => {
setUploadHover((prev) => ({ ...prev, [workspaceId]: true }));
}, []);
const handleUploadMouseLeave = useCallback((workspaceId) => {
setUploadHover((prev) => ({ ...prev, [workspaceId]: false }));
}, []);
if (loading) {
@ -67,14 +75,16 @@ export default function ActiveWorkspaces() {
{workspaces.map((workspace) => {
const isActive = workspace.slug === slug;
const isHovered = hoverStates[workspace.id];
const isGearHovered = settingHover[workspace.id];
return (
<div className="flex flex-col w-full">
<div
className="flex flex-col w-full"
onMouseEnter={() => handleMouseEnter(workspace.id)}
onMouseLeave={() => handleMouseLeave(workspace.id)}
key={workspace.id}
>
<div
key={workspace.id}
className="flex gap-x-2 items-center justify-between"
onMouseEnter={() => handleMouseEnter(workspace.id)}
onMouseLeave={() => handleMouseLeave(workspace.id)}
>
<a
href={isActive ? null : paths.workspace.chat(workspace.slug)}
@ -99,30 +109,55 @@ export default function ActiveWorkspaces() {
isActive ? "" : "text-opacity-80"
}`}
>
{isActive
{isActive || isHovered
? truncate(workspace.name, 17)
: truncate(workspace.name, 20)}
</p>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
setSelectedWs(workspace);
showModal();
}}
onMouseEnter={() => handleGearMouseEnter(workspace.id)}
onMouseLeave={() => handleGearMouseLeave(workspace.id)}
className="rounded-md flex items-center justify-center text-white ml-auto"
>
<GearSix
weight={isGearHovered ? "fill" : "regular"}
hidden={
(!isActive && !isHovered) || user?.role === "default"
}
className="h-[20px] w-[20px] transition-all duration-300"
/>
</button>
{isActive ||
isHovered ||
gearHover[workspace.id] ||
user?.role === "default" ? (
<div className="flex items-center gap-x-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
setSelectedWs(workspace);
showModal();
}}
onMouseEnter={() =>
handleUploadMouseEnter(workspace.id)
}
onMouseLeave={() =>
handleUploadMouseLeave(workspace.id)
}
className="rounded-md flex items-center justify-center text-white ml-auto"
>
<UploadSimple
weight={
uploadHover[workspace.id] ? "fill" : "regular"
}
className="h-[20px] w-[20px] transition-all duration-300"
/>
</button>
<Link
type="button"
to={paths.workspace.settings.generalAppearance(
workspace.slug
)}
onMouseEnter={() => handleGearMouseEnter(workspace.id)}
onMouseLeave={() => handleGearMouseLeave(workspace.id)}
className="rounded-md flex items-center justify-center text-white ml-auto"
>
<GearSix
weight={gearHover[workspace.id] ? "fill" : "regular"}
className="h-[20px] w-[20px] transition-all duration-300"
/>
</Link>
</div>
) : null}
</div>
</a>
</div>

View File

@ -0,0 +1,76 @@
import System from "@/models/system";
import Workspace from "@/models/workspace";
import showToast from "@/utils/toast";
import { castToType } from "@/utils/types";
import { useEffect, useRef, useState } from "react";
import ChatModelSelection from "./ChatModelSelection";
import ChatHistorySettings from "./ChatHistorySettings";
import ChatPromptSettings from "./ChatPromptSettings";
import ChatTemperatureSettings from "./ChatTemperatureSettings";
export default function ChatSettings({ workspace }) {
const [settings, setSettings] = useState({});
const [hasChanges, setHasChanges] = useState(false);
const [saving, setSaving] = useState(false);
const formEl = useRef(null);
useEffect(() => {
async function fetchSettings() {
const _settings = await System.keys();
setSettings(_settings ?? {});
}
fetchSettings();
}, []);
const handleUpdate = async (e) => {
setSaving(true);
e.preventDefault();
const data = {};
const form = new FormData(formEl.current);
for (var [key, value] of form.entries()) data[key] = castToType(key, value);
const { workspace: updatedWorkspace, message } = await Workspace.update(
workspace.slug,
data
);
if (!!updatedWorkspace) {
showToast("Workspace updated!", "success", { clear: true });
} else {
showToast(`Error: ${message}`, "error", { clear: true });
}
setSaving(false);
setHasChanges(false);
};
if (!workspace) return null;
return (
<form
ref={formEl}
onSubmit={handleUpdate}
className="w-1/2 flex flex-col gap-y-6"
>
<ChatModelSelection
settings={settings}
workspace={workspace}
setHasChanges={setHasChanges}
/>
<ChatHistorySettings
workspace={workspace}
setHasChanges={setHasChanges}
/>
<ChatPromptSettings workspace={workspace} setHasChanges={setHasChanges} />
<ChatTemperatureSettings
settings={settings}
workspace={workspace}
setHasChanges={setHasChanges}
/>
{hasChanges && (
<button
type="submit"
className="w-fit transition-all duration-300 border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
>
{saving ? "Updating..." : "Update workspace"}
</button>
)}
</form>
);
}

View File

@ -0,0 +1,74 @@
import Workspace from "@/models/workspace";
import { castToType } from "@/utils/types";
import showToast from "@/utils/toast";
import { useEffect, useRef, useState } from "react";
import VectorCount from "./VectorCount";
import WorkspaceName from "./WorkspaceName";
import SuggestedChatMessages from "./SuggestedChatMessages";
export default function GeneralInfo({ slug }) {
const [workspace, setWorkspace] = useState(null);
const [hasChanges, setHasChanges] = useState(false);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const formEl = useRef(null);
useEffect(() => {
async function fetchWorkspace() {
const workspace = await Workspace.bySlug(slug);
setWorkspace(workspace);
setLoading(false);
}
fetchWorkspace();
}, [slug]);
const handleUpdate = async (e) => {
setSaving(true);
e.preventDefault();
const data = {};
const form = new FormData(formEl.current);
for (var [key, value] of form.entries()) data[key] = castToType(key, value);
const { workspace: updatedWorkspace, message } = await Workspace.update(
workspace.slug,
data
);
if (!!updatedWorkspace) {
showToast("Workspace updated!", "success", { clear: true });
setTimeout(() => window.location.reload(), 1_500);
} else {
showToast(`Error: ${message}`, "error", { clear: true });
}
setSaving(false);
setHasChanges(false);
};
if (!workspace || loading) return null;
return (
<>
<form
ref={formEl}
onSubmit={handleUpdate}
className="w-1/2 flex flex-col gap-y-6"
>
<VectorCount reload={true} workspace={workspace} />
<WorkspaceName
key={workspace.slug}
workspace={workspace}
setHasChanges={setHasChanges}
/>
{hasChanges && (
<button
type="submit"
className="transition-all w-fit duration-300 border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
>
{saving ? "Updating..." : "Update workspace"}
</button>
)}
</form>
<div className="mt-6">
<SuggestedChatMessages slug={workspace.slug} />
</div>
</>
);
}

View File

@ -0,0 +1,56 @@
import Workspace from "@/models/workspace";
import showToast from "@/utils/toast";
import { castToType } from "@/utils/types";
import { useRef, useState } from "react";
import VectorDBIdentifier from "./VectorDBIdentifier";
import MaxContextSnippets from "./MaxContextSnippets";
import DocumentSimilarityThreshold from "./DocumentSimilarityThreshold";
export default function VectorDatabase({ workspace }) {
const [hasChanges, setHasChanges] = useState(false);
const [saving, setSaving] = useState(false);
const formEl = useRef(null);
const handleUpdate = async (e) => {
setSaving(true);
e.preventDefault();
const data = {};
const form = new FormData(formEl.current);
for (var [key, value] of form.entries()) data[key] = castToType(key, value);
const { workspace: updatedWorkspace, message } = await Workspace.update(
workspace.slug,
data
);
if (!!updatedWorkspace) {
showToast("Workspace updated!", "success", { clear: true });
} else {
showToast(`Error: ${message}`, "error", { clear: true });
}
setSaving(false);
setHasChanges(false);
};
if (!workspace) return null;
return (
<form
ref={formEl}
onSubmit={handleUpdate}
className="w-1/2 flex flex-col gap-y-6"
>
<VectorDBIdentifier workspace={workspace} />
<MaxContextSnippets workspace={workspace} setHasChanges={setHasChanges} />
<DocumentSimilarityThreshold
workspace={workspace}
setHasChanges={setHasChanges}
/>
{hasChanges && (
<button
type="submit"
className="w-fit transition-all duration-300 border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
>
{saving ? "Updating..." : "Update workspace"}
</button>
)}
</form>
);
}

View File

@ -1,208 +1,119 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { isMobile } from "react-device-detect";
import showToast from "@/utils/toast";
import { ArrowUUpLeft, Plus, X } from "@phosphor-icons/react";
import Sidebar from "@/components/Sidebar";
import Workspace from "@/models/workspace";
import PasswordModal, { usePasswordModal } from "@/components/Modals/Password";
import { isMobile } from "react-device-detect";
import { FullScreenLoader } from "@/components/Preloader";
import {
ArrowUUpLeft,
ChatText,
Database,
Wrench,
} from "@phosphor-icons/react";
import paths from "@/utils/paths";
import { Link } from "react-router-dom";
import { NavLink } from "react-router-dom";
import GeneralAppearance from "./GeneralAppearance";
import ChatSettings from "./ChatSettings";
import VectorDatabase from "./VectorDatabase";
const TABS = {
"general-appearance": GeneralAppearance,
"chat-settings": ChatSettings,
"vector-database": VectorDatabase,
};
export default function WorkspaceSettings() {
const [hasChanges, setHasChanges] = useState(false);
const { loading, requiresAuth, mode } = usePasswordModal();
if (loading) return <FullScreenLoader />;
if (requiresAuth !== false) {
return <>{requiresAuth !== null && <PasswordModal mode={mode} />}</>;
}
return <ShowWorkspaceChat />;
}
function ShowWorkspaceChat() {
const { slug, tab } = useParams();
const [workspace, setWorkspace] = useState(null);
const [suggestedMessages, setSuggestedMessages] = useState([]);
const [editingIndex, setEditingIndex] = useState(-1);
const [newMessage, setNewMessage] = useState({ heading: "", message: "" });
const { slug } = useParams();
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchWorkspace() {
async function getWorkspace() {
if (!slug) return;
const workspace = await Workspace.bySlug(slug);
const _workspace = await Workspace.bySlug(slug);
if (!_workspace) {
setLoading(false);
return;
}
const suggestedMessages = await Workspace.getSuggestedMessages(slug);
setWorkspace(workspace);
setSuggestedMessages(suggestedMessages);
setWorkspace({
..._workspace,
suggestedMessages,
});
setLoading(false);
}
fetchWorkspace();
getWorkspace();
}, [slug]);
const handleSaveSuggestedMessages = async () => {
const validMessages = suggestedMessages.filter(
(msg) =>
msg?.heading?.trim()?.length > 0 || msg?.message?.trim()?.length > 0
);
const { success, error } = await Workspace.setSuggestedMessages(
slug,
validMessages
);
if (!success) {
showToast(`Failed to update welcome messages: ${error}`, "error");
return;
}
showToast("Successfully updated welcome messages.", "success");
setHasChanges(false);
};
const addMessage = () => {
setEditingIndex(-1);
if (suggestedMessages.length >= 4) {
showToast("Maximum of 4 messages allowed.", "warning");
return;
}
const defaultMessage = {
heading: "Explain to me",
message: "the benefits of AnythingLLM",
};
setNewMessage(defaultMessage);
setSuggestedMessages([...suggestedMessages, { ...defaultMessage }]);
setHasChanges(true);
};
const removeMessage = (index) => {
const messages = [...suggestedMessages];
messages.splice(index, 1);
setSuggestedMessages(messages);
setHasChanges(true);
};
const startEditing = (index) => {
setEditingIndex(index);
setNewMessage({ ...suggestedMessages[index] });
};
const handleRemoveMessage = (index) => {
removeMessage(index);
setEditingIndex(-1);
};
const onEditChange = (e) => {
const updatedNewMessage = {
...newMessage,
[e.target.name]: e.target.value,
};
setNewMessage(updatedNewMessage);
const updatedMessages = suggestedMessages.map((message, index) => {
if (index === editingIndex) {
return { ...message, [e.target.name]: e.target.value };
}
return message;
});
setSuggestedMessages(updatedMessages);
setHasChanges(true);
};
if (loading) return <FullScreenLoader />;
const TabContent = TABS[tab];
return (
<div className="w-screen h-screen overflow-hidden bg-sidebar flex">
<a
href={paths.workspace.chat(slug)}
className="absolute top-2 left-2 md:top-16 md:left-10 transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border z-10"
>
<ArrowUUpLeft className="h-4 w-4" />
</a>
{!isMobile && <Sidebar />}
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="transition-all duration-500 relative md:ml-[16px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent"
className="transition-all duration-500 relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent"
>
<div className="flex flex-col w-full px-1 md:px-20 md:py-12 py-16">
<div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
<div className="items-center flex gap-x-4">
<p className="text-2xl font-semibold text-white">
Workspace Settings ({workspace?.name})
</p>
</div>
<p className="text-sm font-base text-white text-opacity-60">
Customize your workspace.
</p>
</div>
<div className="my-6">
<div className="flex flex-col gap-y-2">
<h2 className="leading-tight font-medium text-white">
Suggested Chat Messages
</h2>
<p className="text-sm font-base text-white/60">
Customize the messages that will be suggested to your workspace
users.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-white/60 text-xs mt-6 w-full justify-center max-w-[600px]">
{suggestedMessages.map((suggestion, index) => (
<div key={index} className="relative w-full">
<button
className="transition-all duration-300 absolute z-10 text-neutral-700 bg-white rounded-full hover:bg-zinc-600 hover:border-zinc-600 hover:text-white border-transparent border shadow-lg ml-2"
style={{
top: -8,
left: 265,
}}
onClick={() => handleRemoveMessage(index)}
>
<X className="m-[1px]" size={20} />
</button>
<button
key={index}
onClick={() => startEditing(index)}
className={`text-left p-2.5 border rounded-xl w-full border-white/20 bg-sidebar hover:bg-workspace-item-selected-gradient ${
editingIndex === index ? "border-sky-400" : ""
}`}
>
<p className="font-semibold">{suggestion.heading}</p>
<p>{suggestion.message}</p>
</button>
</div>
))}
</div>
{editingIndex >= 0 && (
<div className="flex flex-col gap-y-4 mr-2 mt-8">
<div className="w-1/2">
<label className="text-white text-sm font-semibold block mb-2">
Heading
</label>
<input
placeholder="Message heading"
className=" bg-sidebar text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white block w-full p-2.5"
value={newMessage.heading}
name="heading"
onChange={onEditChange}
/>
</div>
<div className="w-1/2">
<label className="text-white text-sm font-semibold block mb-2">
Message
</label>
<input
placeholder="Message"
className="bg-sidebar text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white block w-full p-2.5"
value={newMessage.message}
name="message"
onChange={onEditChange}
/>
</div>
</div>
)}
{suggestedMessages.length < 4 && (
<button
type="button"
onClick={addMessage}
className="flex gap-x-2 items-center justify-center mt-6 text-white text-sm hover:text-sky-400 transition-all duration-300"
>
Add new message <Plus className="" size={24} weight="fill" />
</button>
)}
{hasChanges && (
<div className="flex justify-center py-6">
<button
type="button"
className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
onClick={handleSaveSuggestedMessages}
>
Save Messages
</button>
</div>
)}
</div>
<div className="flex gap-x-10 pt-6 pb-4 ml-16 mr-8 border-b-2 border-white border-opacity-10">
<Link
to={paths.workspace.chat(slug)}
className="absolute top-2 left-2 md:top-4 md:left-4 transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border z-10"
>
<ArrowUUpLeft className="h-4 w-4" />
</Link>
<TabItem
title="General Settings"
icon={<Wrench className="h-6 w-6" />}
to={paths.workspace.settings.generalAppearance(slug)}
/>
<TabItem
title="Chat Settings"
icon={<ChatText className="h-6 w-6" />}
to={paths.workspace.settings.chatSettings(slug)}
/>
<TabItem
title="Vector Database"
icon={<Database className="h-6 w-6" />}
to={paths.workspace.settings.vectorDatabase(slug)}
/>
</div>
<div className="px-16 py-6">
<TabContent slug={slug} workspace={workspace} />
</div>
</div>
</div>
);
}
function TabItem({ title, icon, to }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
`${
isActive
? "text-sky-400 pb-4 border-b-[4px] -mb-[19px] border-sky-400"
: "text-white/60 hover:text-sky-400"
} ` + " flex gap-x-2 items-center font-medium"
}
>
{icon}
<div>{title}</div>
</NavLink>
);
}

View File

@ -55,8 +55,16 @@ export default {
chat: (slug) => {
return `/workspace/${slug}`;
},
additionalSettings: (slug) => {
return `/workspace/${slug}/settings`;
settings: {
generalAppearance: (slug) => {
return `/workspace/${slug}/settings/general-appearance`;
},
chatSettings: (slug) => {
return `/workspace/${slug}/settings/chat-settings`;
},
vectorDatabase: (slug) => {
return `/workspace/${slug}/settings/vector-database`;
},
},
thread: (wsSlug, threadSlug) => {
return `/workspace/${wsSlug}/t/${threadSlug}`;

View File

@ -0,0 +1,19 @@
export function castToType(key, value) {
const definitions = {
openAiTemp: {
cast: (value) => Number(value),
},
openAiHistory: {
cast: (value) => Number(value),
},
similarityThreshold: {
cast: (value) => parseFloat(value),
},
topN: {
cast: (value) => Number(value),
},
};
if (!definitions.hasOwnProperty(key)) return value;
return definitions[key].cast(value);
}