mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-07-04 16:20:12 +02:00
Implement workspace threading that is backwards compatible (#699)
* Implement workspace thread that is compatible with legacy versions * last touches * comment on chat qty enforcement
This commit is contained in:
parent
b985524901
commit
406732830f
|
@ -61,6 +61,10 @@ export default function App() {
|
||||||
path="/workspace/:slug"
|
path="/workspace/:slug"
|
||||||
element={<PrivateRoute Component={WorkspaceChat} />}
|
element={<PrivateRoute Component={WorkspaceChat} />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/workspace/:slug/t/:threadSlug"
|
||||||
|
element={<PrivateRoute Component={WorkspaceChat} />}
|
||||||
|
/>
|
||||||
<Route path="/accept-invite/:code" element={<InvitePage />} />
|
<Route path="/accept-invite/:code" element={<InvitePage />} />
|
||||||
|
|
||||||
{/* Admin */}
|
{/* Admin */}
|
||||||
|
|
|
@ -0,0 +1,189 @@
|
||||||
|
import Workspace from "@/models/workspace";
|
||||||
|
import paths from "@/utils/paths";
|
||||||
|
import showToast from "@/utils/toast";
|
||||||
|
import { DotsThree, PencilSimple, Trash } from "@phosphor-icons/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import truncate from "truncate";
|
||||||
|
|
||||||
|
const THREAD_CALLOUT_DETAIL_WIDTH = 26;
|
||||||
|
export default function ThreadItem({ workspace, thread, onRemove, hasNext }) {
|
||||||
|
const optionsContainer = useRef(null);
|
||||||
|
const { slug, threadSlug = null } = useParams();
|
||||||
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
const [name, setName] = useState(thread.name);
|
||||||
|
|
||||||
|
const isActive = threadSlug === thread.slug;
|
||||||
|
const linkTo = !thread.slug
|
||||||
|
? paths.workspace.chat(slug)
|
||||||
|
: paths.workspace.thread(slug, thread.slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full relative flex h-[40px] items-center border-none hover:bg-slate-600/20 rounded-lg">
|
||||||
|
{/* Curved line Element and leader if required */}
|
||||||
|
<div
|
||||||
|
style={{ width: THREAD_CALLOUT_DETAIL_WIDTH / 2 }}
|
||||||
|
className="border-l border-b border-slate-300 h-[50%] absolute top-0 left-2 rounded-bl-lg"
|
||||||
|
></div>
|
||||||
|
{hasNext && (
|
||||||
|
<div
|
||||||
|
style={{ width: THREAD_CALLOUT_DETAIL_WIDTH / 2 }}
|
||||||
|
className="border-l border-slate-300 h-[100%] absolute top-0 left-2"
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Curved line inline placeholder for spacing */}
|
||||||
|
<div
|
||||||
|
style={{ width: THREAD_CALLOUT_DETAIL_WIDTH }}
|
||||||
|
className="w-[26px] h-full"
|
||||||
|
/>
|
||||||
|
<div className="flex w-full items-center justify-between pr-2 group relative">
|
||||||
|
<a href={isActive ? "#" : linkTo} className="w-full">
|
||||||
|
<p
|
||||||
|
className={`text-left text-sm ${
|
||||||
|
isActive
|
||||||
|
? "font-semibold text-slate-300"
|
||||||
|
: "text-slate-400 italic"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{truncate(name, 25)}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{!!thread.slug && (
|
||||||
|
<div ref={optionsContainer}>
|
||||||
|
<div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOptions(!showOptions)}
|
||||||
|
>
|
||||||
|
<DotsThree className="text-slate-300" size={25} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showOptions && (
|
||||||
|
<OptionsMenu
|
||||||
|
containerRef={optionsContainer}
|
||||||
|
workspace={workspace}
|
||||||
|
thread={thread}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onRename={setName}
|
||||||
|
close={() => setShowOptions(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionsMenu({
|
||||||
|
containerRef,
|
||||||
|
workspace,
|
||||||
|
thread,
|
||||||
|
onRename,
|
||||||
|
onRemove,
|
||||||
|
close,
|
||||||
|
}) {
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
// Ref menu options
|
||||||
|
const outsideClick = (e) => {
|
||||||
|
if (!menuRef.current) return false;
|
||||||
|
if (
|
||||||
|
!menuRef.current?.contains(e.target) &&
|
||||||
|
!containerRef.current?.contains(e.target)
|
||||||
|
)
|
||||||
|
close();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEsc = (e) => {
|
||||||
|
if (e.key === "Escape" || e.key === "Esc") close();
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanupListeners() {
|
||||||
|
window.removeEventListener("click", outsideClick);
|
||||||
|
window.removeEventListener("keyup", isEsc);
|
||||||
|
}
|
||||||
|
// end Ref menu options
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function setListeners() {
|
||||||
|
if (!menuRef?.current || !containerRef.current) return false;
|
||||||
|
window.document.addEventListener("click", outsideClick);
|
||||||
|
window.document.addEventListener("keyup", isEsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
setListeners();
|
||||||
|
return cleanupListeners;
|
||||||
|
}, [menuRef.current, containerRef.current]);
|
||||||
|
|
||||||
|
const renameThread = async () => {
|
||||||
|
const name = window
|
||||||
|
.prompt("What would you like to rename this thread to?")
|
||||||
|
?.trim();
|
||||||
|
if (!name || name.length === 0) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = await Workspace.threads.update(
|
||||||
|
workspace.slug,
|
||||||
|
thread.slug,
|
||||||
|
{ name }
|
||||||
|
);
|
||||||
|
if (!!message) {
|
||||||
|
showToast(`Thread could not be updated! ${message}`, "error", {
|
||||||
|
clear: true,
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRename(name);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Are you sure you want to delete this thread? All of its chats will be deleted. You cannot undo this."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const success = await Workspace.threads.delete(workspace.slug, thread.slug);
|
||||||
|
if (!success) {
|
||||||
|
showToast("Thread could not be deleted!", "error", { clear: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
showToast("Thread deleted successfully!", "success", { clear: true });
|
||||||
|
onRemove(thread.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute w-fit z-[20] top-[25px] right-[10px] bg-zinc-900 rounded-lg p-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={renameThread}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-md flex items-center p-2 gap-x-2 hover:bg-slate-500/20 text-slate-300"
|
||||||
|
>
|
||||||
|
<PencilSimple size={18} />
|
||||||
|
<p className="text-sm">Rename</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-md flex items-center p-2 gap-x-2 hover:bg-red-500/20 text-slate-300 hover:text-red-100"
|
||||||
|
>
|
||||||
|
<Trash size={18} />
|
||||||
|
<p className="text-sm">Delete Thread</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import Workspace from "@/models/workspace";
|
||||||
|
import paths from "@/utils/paths";
|
||||||
|
import showToast from "@/utils/toast";
|
||||||
|
import { Plus, CircleNotch } from "@phosphor-icons/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ThreadItem from "./ThreadItem";
|
||||||
|
|
||||||
|
export default function ThreadContainer({ workspace }) {
|
||||||
|
const [threads, setThreads] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchThreads() {
|
||||||
|
if (!workspace.slug) return;
|
||||||
|
const { threads } = await Workspace.threads.all(workspace.slug);
|
||||||
|
setLoading(false);
|
||||||
|
setThreads(threads);
|
||||||
|
}
|
||||||
|
fetchThreads();
|
||||||
|
}, [workspace.slug]);
|
||||||
|
|
||||||
|
function removeThread(threadId) {
|
||||||
|
setThreads((prev) => prev.filter((thread) => thread.id !== threadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col bg-pulse w-full h-10 items-center justify-center">
|
||||||
|
<p className="text-xs text-slate-600 animate-pulse">
|
||||||
|
loading threads....
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ThreadItem
|
||||||
|
thread={{ slug: null, name: "default" }}
|
||||||
|
hasNext={threads.length > 0}
|
||||||
|
/>
|
||||||
|
{threads.map((thread, i) => (
|
||||||
|
<ThreadItem
|
||||||
|
key={thread.slug}
|
||||||
|
workspace={workspace}
|
||||||
|
onRemove={removeThread}
|
||||||
|
thread={thread}
|
||||||
|
hasNext={i !== threads.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<NewThreadButton workspace={workspace} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewThreadButton({ workspace }) {
|
||||||
|
const [loading, setLoading] = useState();
|
||||||
|
const onClick = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const { thread, error } = await Workspace.threads.new(workspace.slug);
|
||||||
|
if (!!error) {
|
||||||
|
showToast(`Could not create thread - ${error}`, "error", { clear: true });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.replace(
|
||||||
|
paths.workspace.thread(workspace.slug, thread.slug)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full relative flex h-[40px] items-center border-none hover:bg-slate-600/20 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex w-full gap-x-2 items-center pl-4">
|
||||||
|
{loading ? (
|
||||||
|
<CircleNotch className="animate-spin text-slate-300" />
|
||||||
|
) : (
|
||||||
|
<Plus className="text-slate-300" />
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-left text-slate-300 text-sm">starting thread...</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-left text-slate-300 text-sm">new thread</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import { useParams } from "react-router-dom";
|
||||||
import { GearSix, SquaresFour } from "@phosphor-icons/react";
|
import { GearSix, SquaresFour } from "@phosphor-icons/react";
|
||||||
import truncate from "truncate";
|
import truncate from "truncate";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
|
import ThreadContainer from "./ThreadContainer";
|
||||||
|
|
||||||
export default function ActiveWorkspaces() {
|
export default function ActiveWorkspaces() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
|
@ -68,15 +69,16 @@ export default function ActiveWorkspaces() {
|
||||||
const isHovered = hoverStates[workspace.id];
|
const isHovered = hoverStates[workspace.id];
|
||||||
const isGearHovered = settingHover[workspace.id];
|
const isGearHovered = settingHover[workspace.id];
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col w-full">
|
||||||
key={workspace.id}
|
<div
|
||||||
className="flex gap-x-2 items-center justify-between"
|
key={workspace.id}
|
||||||
onMouseEnter={() => handleMouseEnter(workspace.id)}
|
className="flex gap-x-2 items-center justify-between"
|
||||||
onMouseLeave={() => handleMouseLeave(workspace.id)}
|
onMouseEnter={() => handleMouseEnter(workspace.id)}
|
||||||
>
|
onMouseLeave={() => handleMouseLeave(workspace.id)}
|
||||||
<a
|
>
|
||||||
href={isActive ? null : paths.workspace.chat(workspace.slug)}
|
<a
|
||||||
className={`
|
href={isActive ? null : paths.workspace.chat(workspace.slug)}
|
||||||
|
className={`
|
||||||
transition-all duration-[200ms]
|
transition-all duration-[200ms]
|
||||||
flex flex-grow w-[75%] gap-x-2 py-[6px] px-[12px] rounded-lg text-slate-200 justify-start items-center border
|
flex flex-grow w-[75%] gap-x-2 py-[6px] px-[12px] rounded-lg text-slate-200 justify-start items-center border
|
||||||
hover:bg-workspace-item-selected-gradient hover:border-slate-100 hover:border-opacity-50
|
hover:bg-workspace-item-selected-gradient hover:border-slate-100 hover:border-opacity-50
|
||||||
|
@ -85,44 +87,48 @@ export default function ActiveWorkspaces() {
|
||||||
? "bg-workspace-item-selected-gradient border-slate-100 border-opacity-50"
|
? "bg-workspace-item-selected-gradient border-slate-100 border-opacity-50"
|
||||||
: "bg-workspace-item-gradient bg-opacity-60 border-transparent"
|
: "bg-workspace-item-gradient bg-opacity-60 border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row justify-between w-full">
|
<div className="flex flex-row justify-between w-full">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<SquaresFour
|
<SquaresFour
|
||||||
weight={isActive ? "fill" : "regular"}
|
weight={isActive ? "fill" : "regular"}
|
||||||
className="h-5 w-5 flex-shrink-0"
|
className="h-5 w-5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
className={`text-white text-sm leading-loose font-medium whitespace-nowrap overflow-hidden ${
|
className={`text-white text-sm leading-loose font-medium whitespace-nowrap overflow-hidden ${
|
||||||
isActive ? "" : "text-opacity-80"
|
isActive ? "" : "text-opacity-80"
|
||||||
}`}
|
}`}
|
||||||
|
>
|
||||||
|
{isActive
|
||||||
|
? 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"
|
||||||
>
|
>
|
||||||
{isActive
|
<GearSix
|
||||||
? truncate(workspace.name, 17)
|
weight={isGearHovered ? "fill" : "regular"}
|
||||||
: truncate(workspace.name, 20)}
|
hidden={
|
||||||
</p>
|
(!isActive && !isHovered) || user?.role === "default"
|
||||||
|
}
|
||||||
|
className="h-[20px] w-[20px] transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</a>
|
||||||
type="button"
|
</div>
|
||||||
onClick={(e) => {
|
{isActive && (
|
||||||
e.preventDefault();
|
<ThreadContainer workspace={workspace} isActive={isActive} />
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -5,8 +5,10 @@ import Workspace from "@/models/workspace";
|
||||||
import handleChat from "@/utils/chat";
|
import handleChat from "@/utils/chat";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { SidebarMobileHeader } from "../../Sidebar";
|
import { SidebarMobileHeader } from "../../Sidebar";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function ChatContainer({ workspace, knownHistory = [] }) {
|
export default function ChatContainer({ workspace, knownHistory = [] }) {
|
||||||
|
const { threadSlug = null } = useParams();
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [loadingResponse, setLoadingResponse] = useState(false);
|
const [loadingResponse, setLoadingResponse] = useState(false);
|
||||||
const [chatHistory, setChatHistory] = useState(knownHistory);
|
const [chatHistory, setChatHistory] = useState(knownHistory);
|
||||||
|
@ -71,20 +73,39 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Workspace.streamChat(
|
if (!!threadSlug) {
|
||||||
workspace,
|
await Workspace.threads.streamChat(
|
||||||
promptMessage.userMessage,
|
{ workspaceSlug: workspace.slug, threadSlug },
|
||||||
window.localStorage.getItem(`workspace_chat_mode_${workspace.slug}`) ??
|
promptMessage.userMessage,
|
||||||
"chat",
|
window.localStorage.getItem(
|
||||||
(chatResult) =>
|
`workspace_chat_mode_${workspace.slug}`
|
||||||
handleChat(
|
) ?? "chat",
|
||||||
chatResult,
|
(chatResult) =>
|
||||||
setLoadingResponse,
|
handleChat(
|
||||||
setChatHistory,
|
chatResult,
|
||||||
remHistory,
|
setLoadingResponse,
|
||||||
_chatHistory
|
setChatHistory,
|
||||||
)
|
remHistory,
|
||||||
);
|
_chatHistory
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await Workspace.streamChat(
|
||||||
|
workspace,
|
||||||
|
promptMessage.userMessage,
|
||||||
|
window.localStorage.getItem(
|
||||||
|
`workspace_chat_mode_${workspace.slug}`
|
||||||
|
) ?? "chat",
|
||||||
|
(chatResult) =>
|
||||||
|
handleChat(
|
||||||
|
chatResult,
|
||||||
|
setLoadingResponse,
|
||||||
|
setChatHistory,
|
||||||
|
remHistory,
|
||||||
|
_chatHistory
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadingResponse === true && fetchReply();
|
loadingResponse === true && fetchReply();
|
||||||
|
|
|
@ -4,8 +4,10 @@ import LoadingChat from "./LoadingChat";
|
||||||
import ChatContainer from "./ChatContainer";
|
import ChatContainer from "./ChatContainer";
|
||||||
import paths from "@/utils/paths";
|
import paths from "@/utils/paths";
|
||||||
import ModalWrapper from "../ModalWrapper";
|
import ModalWrapper from "../ModalWrapper";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function WorkspaceChat({ loading, workspace }) {
|
export default function WorkspaceChat({ loading, workspace }) {
|
||||||
|
const { threadSlug = null } = useParams();
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(true);
|
const [loadingHistory, setLoadingHistory] = useState(true);
|
||||||
|
|
||||||
|
@ -17,7 +19,9 @@ export default function WorkspaceChat({ loading, workspace }) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatHistory = await Workspace.chatHistory(workspace.slug);
|
const chatHistory = threadSlug
|
||||||
|
? await Workspace.threads.chatHistory(workspace.slug, threadSlug)
|
||||||
|
: await Workspace.chatHistory(workspace.slug);
|
||||||
setHistory(chatHistory);
|
setHistory(chatHistory);
|
||||||
setLoadingHistory(false);
|
setLoadingHistory(false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { API_BASE } from "@/utils/constants";
|
import { API_BASE } from "@/utils/constants";
|
||||||
import { baseHeaders } from "@/utils/request";
|
import { baseHeaders } from "@/utils/request";
|
||||||
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
||||||
|
import WorkspaceThread from "@/models/workspaceThread";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
const Workspace = {
|
const Workspace = {
|
||||||
|
@ -204,6 +205,7 @@ const Workspace = {
|
||||||
return { success: false, error: e.message };
|
return { success: false, error: e.message };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
threads: WorkspaceThread,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Workspace;
|
export default Workspace;
|
||||||
|
|
146
frontend/src/models/workspaceThread.js
Normal file
146
frontend/src/models/workspaceThread.js
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import { API_BASE } from "@/utils/constants";
|
||||||
|
import { baseHeaders } from "@/utils/request";
|
||||||
|
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
const WorkspaceThread = {
|
||||||
|
all: async function (workspaceSlug) {
|
||||||
|
const { threads } = await fetch(
|
||||||
|
`${API_BASE}/workspace/${workspaceSlug}/threads`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch((e) => {
|
||||||
|
return { threads: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { threads };
|
||||||
|
},
|
||||||
|
new: async function (workspaceSlug) {
|
||||||
|
const { thread, error } = await fetch(
|
||||||
|
`${API_BASE}/workspace/${workspaceSlug}/thread/new`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch((e) => {
|
||||||
|
return { thread: null, error: e.message };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { thread, error };
|
||||||
|
},
|
||||||
|
update: async function (workspaceSlug, threadSlug, data = {}) {
|
||||||
|
const { thread, message } = await fetch(
|
||||||
|
`${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/update`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: baseHeaders(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch((e) => {
|
||||||
|
return { thread: null, message: e.message };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { thread, message };
|
||||||
|
},
|
||||||
|
delete: async function (workspaceSlug, threadSlug) {
|
||||||
|
return await fetch(
|
||||||
|
`${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => res.ok)
|
||||||
|
.catch(() => false);
|
||||||
|
},
|
||||||
|
chatHistory: async function (workspaceSlug, threadSlug) {
|
||||||
|
const history = await fetch(
|
||||||
|
`${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/chats`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((res) => res.history || [])
|
||||||
|
.catch(() => []);
|
||||||
|
return history;
|
||||||
|
},
|
||||||
|
streamChat: async function (
|
||||||
|
{ workspaceSlug, threadSlug },
|
||||||
|
message,
|
||||||
|
mode = "query",
|
||||||
|
handleChat
|
||||||
|
) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
await fetchEventSource(
|
||||||
|
`${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/stream-chat`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ message, mode }),
|
||||||
|
headers: baseHeaders(),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
openWhenHidden: true,
|
||||||
|
async onopen(response) {
|
||||||
|
if (response.ok) {
|
||||||
|
return; // everything's good
|
||||||
|
} else if (
|
||||||
|
response.status >= 400 &&
|
||||||
|
response.status < 500 &&
|
||||||
|
response.status !== 429
|
||||||
|
) {
|
||||||
|
handleChat({
|
||||||
|
id: v4(),
|
||||||
|
type: "abort",
|
||||||
|
textResponse: null,
|
||||||
|
sources: [],
|
||||||
|
close: true,
|
||||||
|
error: `An error occurred while streaming response. Code ${response.status}`,
|
||||||
|
});
|
||||||
|
ctrl.abort();
|
||||||
|
throw new Error("Invalid Status code response.");
|
||||||
|
} else {
|
||||||
|
handleChat({
|
||||||
|
id: v4(),
|
||||||
|
type: "abort",
|
||||||
|
textResponse: null,
|
||||||
|
sources: [],
|
||||||
|
close: true,
|
||||||
|
error: `An error occurred while streaming response. Unknown Error.`,
|
||||||
|
});
|
||||||
|
ctrl.abort();
|
||||||
|
throw new Error("Unknown error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onmessage(msg) {
|
||||||
|
try {
|
||||||
|
const chatResult = JSON.parse(msg.data);
|
||||||
|
handleChat(chatResult);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
onerror(err) {
|
||||||
|
handleChat({
|
||||||
|
id: v4(),
|
||||||
|
type: "abort",
|
||||||
|
textResponse: null,
|
||||||
|
sources: [],
|
||||||
|
close: true,
|
||||||
|
error: `An error occurred while streaming response. ${err.message}`,
|
||||||
|
});
|
||||||
|
ctrl.abort();
|
||||||
|
throw new Error();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceThread;
|
|
@ -19,7 +19,7 @@ export default function WorkspaceChat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShowWorkspaceChat() {
|
function ShowWorkspaceChat() {
|
||||||
const { slug } = useParams();
|
const { slug, threadSlug = null } = useParams();
|
||||||
const [workspace, setWorkspace] = useState(null);
|
const [workspace, setWorkspace] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
@ -27,6 +27,10 @@ function ShowWorkspaceChat() {
|
||||||
async function getWorkspace() {
|
async function getWorkspace() {
|
||||||
if (!slug) return;
|
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);
|
const suggestedMessages = await Workspace.getSuggestedMessages(slug);
|
||||||
setWorkspace({
|
setWorkspace({
|
||||||
..._workspace,
|
..._workspace,
|
||||||
|
|
|
@ -58,6 +58,9 @@ export default {
|
||||||
additionalSettings: (slug) => {
|
additionalSettings: (slug) => {
|
||||||
return `/workspace/${slug}/settings`;
|
return `/workspace/${slug}/settings`;
|
||||||
},
|
},
|
||||||
|
thread: (wsSlug, threadSlug) => {
|
||||||
|
return `/workspace/${wsSlug}/t/${threadSlug}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
apiDocs: () => {
|
apiDocs: () => {
|
||||||
return `${API_BASE}/docs`;
|
return `${API_BASE}/docs`;
|
||||||
|
|
|
@ -15,6 +15,9 @@ const {
|
||||||
flexUserRoleValid,
|
flexUserRoleValid,
|
||||||
} = require("../utils/middleware/multiUserProtected");
|
} = require("../utils/middleware/multiUserProtected");
|
||||||
const { EventLogs } = require("../models/eventLogs");
|
const { EventLogs } = require("../models/eventLogs");
|
||||||
|
const {
|
||||||
|
validWorkspaceAndThreadSlug,
|
||||||
|
} = require("../utils/middleware/validWorkspace");
|
||||||
|
|
||||||
function chatEndpoints(app) {
|
function chatEndpoints(app) {
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
@ -123,6 +126,117 @@ function chatEndpoints(app) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/workspace/:slug/thread/:threadSlug/stream-chat",
|
||||||
|
[
|
||||||
|
validatedRequest,
|
||||||
|
flexUserRoleValid([ROLES.all]),
|
||||||
|
validWorkspaceAndThreadSlug,
|
||||||
|
],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const user = await userFromSession(request, response);
|
||||||
|
const { message, mode = "query" } = reqBody(request);
|
||||||
|
const workspace = response.locals.workspace;
|
||||||
|
const thread = response.locals.thread;
|
||||||
|
|
||||||
|
if (!message?.length || !VALID_CHAT_MODE.includes(mode)) {
|
||||||
|
response.status(400).json({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: "abort",
|
||||||
|
textResponse: null,
|
||||||
|
sources: [],
|
||||||
|
close: true,
|
||||||
|
error: !message?.length
|
||||||
|
? "Message is empty."
|
||||||
|
: `${mode} is not a valid mode.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setHeader("Cache-Control", "no-cache");
|
||||||
|
response.setHeader("Content-Type", "text/event-stream");
|
||||||
|
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
response.setHeader("Connection", "keep-alive");
|
||||||
|
response.flushHeaders();
|
||||||
|
|
||||||
|
if (multiUserMode(response) && user.role !== ROLES.admin) {
|
||||||
|
const limitMessagesSetting = await SystemSettings.get({
|
||||||
|
label: "limit_user_messages",
|
||||||
|
});
|
||||||
|
const limitMessages = limitMessagesSetting?.value === "true";
|
||||||
|
|
||||||
|
if (limitMessages) {
|
||||||
|
const messageLimitSetting = await SystemSettings.get({
|
||||||
|
label: "message_limit",
|
||||||
|
});
|
||||||
|
const systemLimit = Number(messageLimitSetting?.value);
|
||||||
|
|
||||||
|
if (!!systemLimit) {
|
||||||
|
// Chat qty includes all threads because any user can freely
|
||||||
|
// create threads and would bypass this rule.
|
||||||
|
const currentChatCount = await WorkspaceChats.count({
|
||||||
|
user_id: user.id,
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date(new Date() - 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentChatCount >= systemLimit) {
|
||||||
|
writeResponseChunk(response, {
|
||||||
|
id: uuidv4(),
|
||||||
|
type: "abort",
|
||||||
|
textResponse: null,
|
||||||
|
sources: [],
|
||||||
|
close: true,
|
||||||
|
error: `You have met your maximum 24 hour chat quota of ${systemLimit} chats set by the instance administrators. Try again later.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await streamChatWithWorkspace(
|
||||||
|
response,
|
||||||
|
workspace,
|
||||||
|
message,
|
||||||
|
mode,
|
||||||
|
user,
|
||||||
|
thread
|
||||||
|
);
|
||||||
|
await Telemetry.sendTelemetry("sent_chat", {
|
||||||
|
multiUserMode: multiUserMode(response),
|
||||||
|
LLMSelection: process.env.LLM_PROVIDER || "openai",
|
||||||
|
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
|
||||||
|
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
|
||||||
|
});
|
||||||
|
|
||||||
|
await EventLogs.logEvent(
|
||||||
|
"sent_chat",
|
||||||
|
{
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
thread: thread.name,
|
||||||
|
chatModel: workspace?.chatModel || "System Default",
|
||||||
|
},
|
||||||
|
user?.id
|
||||||
|
);
|
||||||
|
response.end();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
writeResponseChunk(response, {
|
||||||
|
id: uuidv4(),
|
||||||
|
type: "abort",
|
||||||
|
textResponse: null,
|
||||||
|
sources: [],
|
||||||
|
close: true,
|
||||||
|
error: e.message,
|
||||||
|
});
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { chatEndpoints };
|
module.exports = { chatEndpoints };
|
||||||
|
|
150
server/endpoints/workspaceThreads.js
Normal file
150
server/endpoints/workspaceThreads.js
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
const { multiUserMode, userFromSession, reqBody } = require("../utils/http");
|
||||||
|
const { validatedRequest } = require("../utils/middleware/validatedRequest");
|
||||||
|
const { Telemetry } = require("../models/telemetry");
|
||||||
|
const {
|
||||||
|
flexUserRoleValid,
|
||||||
|
ROLES,
|
||||||
|
} = require("../utils/middleware/multiUserProtected");
|
||||||
|
const { EventLogs } = require("../models/eventLogs");
|
||||||
|
const { WorkspaceThread } = require("../models/workspaceThread");
|
||||||
|
const {
|
||||||
|
validWorkspaceSlug,
|
||||||
|
validWorkspaceAndThreadSlug,
|
||||||
|
} = require("../utils/middleware/validWorkspace");
|
||||||
|
const { WorkspaceChats } = require("../models/workspaceChats");
|
||||||
|
const { convertToChatHistory } = require("../utils/chats");
|
||||||
|
|
||||||
|
function workspaceThreadEndpoints(app) {
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/workspace/:slug/thread/new",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const user = await userFromSession(request, response);
|
||||||
|
const workspace = response.locals.workspace;
|
||||||
|
const { thread, message } = await WorkspaceThread.new(
|
||||||
|
workspace,
|
||||||
|
user?.id
|
||||||
|
);
|
||||||
|
await Telemetry.sendTelemetry(
|
||||||
|
"workspace_thread_created",
|
||||||
|
{
|
||||||
|
multiUserMode: multiUserMode(response),
|
||||||
|
LLMSelection: process.env.LLM_PROVIDER || "openai",
|
||||||
|
Embedder: process.env.EMBEDDING_ENGINE || "inherit",
|
||||||
|
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
|
||||||
|
},
|
||||||
|
user?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await EventLogs.logEvent(
|
||||||
|
"workspace_thread_created",
|
||||||
|
{
|
||||||
|
workspaceName: workspace?.name || "Unknown Workspace",
|
||||||
|
},
|
||||||
|
user?.id
|
||||||
|
);
|
||||||
|
response.status(200).json({ thread, message });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e.message, e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/workspace/:slug/threads",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const user = await userFromSession(request, response);
|
||||||
|
const workspace = response.locals.workspace;
|
||||||
|
const threads = await WorkspaceThread.where({
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
user_id: user?.id || null,
|
||||||
|
});
|
||||||
|
response.status(200).json({ threads });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e.message, e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
"/workspace/:slug/thread/:threadSlug",
|
||||||
|
[
|
||||||
|
validatedRequest,
|
||||||
|
flexUserRoleValid([ROLES.all]),
|
||||||
|
validWorkspaceAndThreadSlug,
|
||||||
|
],
|
||||||
|
async (_, response) => {
|
||||||
|
try {
|
||||||
|
const thread = response.locals.thread;
|
||||||
|
await WorkspaceThread.delete({ id: thread.id });
|
||||||
|
response.sendStatus(200).end();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e.message, e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/workspace/:slug/thread/:threadSlug/chats",
|
||||||
|
[
|
||||||
|
validatedRequest,
|
||||||
|
flexUserRoleValid([ROLES.all]),
|
||||||
|
validWorkspaceAndThreadSlug,
|
||||||
|
],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const user = await userFromSession(request, response);
|
||||||
|
const workspace = response.locals.workspace;
|
||||||
|
const thread = response.locals.thread;
|
||||||
|
const history = await WorkspaceChats.where(
|
||||||
|
{
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
user_id: user?.id || null,
|
||||||
|
thread_id: thread.id,
|
||||||
|
include: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ id: "asc" }
|
||||||
|
);
|
||||||
|
|
||||||
|
response.status(200).json({ history: convertToChatHistory(history) });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e.message, e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/workspace/:slug/thread/:threadSlug/update",
|
||||||
|
[
|
||||||
|
validatedRequest,
|
||||||
|
flexUserRoleValid([ROLES.all]),
|
||||||
|
validWorkspaceAndThreadSlug,
|
||||||
|
],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const data = reqBody(request);
|
||||||
|
const currentThread = response.locals.thread;
|
||||||
|
const { thread, message } = await WorkspaceThread.update(
|
||||||
|
currentThread,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
response.status(200).json({ thread, message });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e.message, e);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { workspaceThreadEndpoints };
|
|
@ -19,6 +19,7 @@ const { utilEndpoints } = require("./endpoints/utils");
|
||||||
const { developerEndpoints } = require("./endpoints/api");
|
const { developerEndpoints } = require("./endpoints/api");
|
||||||
const { extensionEndpoints } = require("./endpoints/extensions");
|
const { extensionEndpoints } = require("./endpoints/extensions");
|
||||||
const { bootHTTP, bootSSL } = require("./utils/boot");
|
const { bootHTTP, bootSSL } = require("./utils/boot");
|
||||||
|
const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
|
||||||
const app = express();
|
const app = express();
|
||||||
const apiRouter = express.Router();
|
const apiRouter = express.Router();
|
||||||
const FILE_LIMIT = "3GB";
|
const FILE_LIMIT = "3GB";
|
||||||
|
@ -37,6 +38,7 @@ app.use("/api", apiRouter);
|
||||||
systemEndpoints(apiRouter);
|
systemEndpoints(apiRouter);
|
||||||
extensionEndpoints(apiRouter);
|
extensionEndpoints(apiRouter);
|
||||||
workspaceEndpoints(apiRouter);
|
workspaceEndpoints(apiRouter);
|
||||||
|
workspaceThreadEndpoints(apiRouter);
|
||||||
chatEndpoints(apiRouter);
|
chatEndpoints(apiRouter);
|
||||||
adminEndpoints(apiRouter);
|
adminEndpoints(apiRouter);
|
||||||
inviteEndpoints(apiRouter);
|
inviteEndpoints(apiRouter);
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
const prisma = require("../utils/prisma");
|
const prisma = require("../utils/prisma");
|
||||||
|
|
||||||
const WorkspaceChats = {
|
const WorkspaceChats = {
|
||||||
new: async function ({ workspaceId, prompt, response = {}, user = null }) {
|
new: async function ({
|
||||||
|
workspaceId,
|
||||||
|
prompt,
|
||||||
|
response = {},
|
||||||
|
user = null,
|
||||||
|
threadId = null,
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
const chat = await prisma.workspace_chats.create({
|
const chat = await prisma.workspace_chats.create({
|
||||||
data: {
|
data: {
|
||||||
|
@ -9,6 +15,7 @@ const WorkspaceChats = {
|
||||||
prompt,
|
prompt,
|
||||||
response: JSON.stringify(response),
|
response: JSON.stringify(response),
|
||||||
user_id: user?.id || null,
|
user_id: user?.id || null,
|
||||||
|
thread_id: threadId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { chat, message: null };
|
return { chat, message: null };
|
||||||
|
@ -30,6 +37,7 @@ const WorkspaceChats = {
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
thread_id: null, // this function is now only used for the default thread on workspaces and users
|
||||||
include: true,
|
include: true,
|
||||||
},
|
},
|
||||||
...(limit !== null ? { take: limit } : {}),
|
...(limit !== null ? { take: limit } : {}),
|
||||||
|
@ -52,6 +60,7 @@ const WorkspaceChats = {
|
||||||
const chats = await prisma.workspace_chats.findMany({
|
const chats = await prisma.workspace_chats.findMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
thread_id: null, // this function is now only used for the default thread on workspaces
|
||||||
include: true,
|
include: true,
|
||||||
},
|
},
|
||||||
...(limit !== null ? { take: limit } : {}),
|
...(limit !== null ? { take: limit } : {}),
|
||||||
|
@ -82,6 +91,29 @@ const WorkspaceChats = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markThreadHistoryInvalid: async function (
|
||||||
|
workspaceId = null,
|
||||||
|
user = null,
|
||||||
|
threadId = null
|
||||||
|
) {
|
||||||
|
if (!workspaceId || !threadId) return;
|
||||||
|
try {
|
||||||
|
await prisma.workspace_chats.updateMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
thread_id: threadId,
|
||||||
|
user_id: user?.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
include: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
get: async function (clause = {}, limit = null, orderBy = null) {
|
get: async function (clause = {}, limit = null, orderBy = null) {
|
||||||
try {
|
try {
|
||||||
const chat = await prisma.workspace_chats.findFirst({
|
const chat = await prisma.workspace_chats.findFirst({
|
||||||
|
|
86
server/models/workspaceThread.js
Normal file
86
server/models/workspaceThread.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
const prisma = require("../utils/prisma");
|
||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
|
||||||
|
const WorkspaceThread = {
|
||||||
|
writable: ["name"],
|
||||||
|
|
||||||
|
new: async function (workspace, userId = null) {
|
||||||
|
try {
|
||||||
|
const thread = await prisma.workspace_threads.create({
|
||||||
|
data: {
|
||||||
|
name: "New thread",
|
||||||
|
slug: uuidv4(),
|
||||||
|
user_id: userId ? Number(userId) : null,
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { thread, message: null };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return { thread: null, message: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async function (prevThread = null, data = {}) {
|
||||||
|
if (!prevThread) throw new Error("No thread id provided for update");
|
||||||
|
|
||||||
|
const validKeys = Object.keys(data).filter((key) =>
|
||||||
|
this.writable.includes(key)
|
||||||
|
);
|
||||||
|
if (validKeys.length === 0)
|
||||||
|
return { thread: prevThread, message: "No valid fields to update!" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thread = await prisma.workspace_threads.update({
|
||||||
|
where: { id: prevThread.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return { thread, message: null };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return { thread: null, message: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async function (clause = {}) {
|
||||||
|
try {
|
||||||
|
const thread = await prisma.workspace_threads.findFirst({
|
||||||
|
where: clause,
|
||||||
|
});
|
||||||
|
|
||||||
|
return thread || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async function (clause = {}) {
|
||||||
|
try {
|
||||||
|
await prisma.workspace_threads.delete({
|
||||||
|
where: clause,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
where: async function (clause = {}, limit = null, orderBy = null) {
|
||||||
|
try {
|
||||||
|
const results = await prisma.workspace_threads.findMany({
|
||||||
|
where: clause,
|
||||||
|
...(limit !== null ? { take: limit } : {}),
|
||||||
|
...(orderBy !== null ? { orderBy } : {}),
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { WorkspaceThread };
|
24
server/prisma/migrations/20240208224848_init/migration.sql
Normal file
24
server/prisma/migrations/20240208224848_init/migration.sql
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "workspace_chats" ADD COLUMN "thread_id" INTEGER;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "workspace_threads" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"workspace_id" INTEGER NOT NULL,
|
||||||
|
"user_id" INTEGER,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "workspace_threads_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "workspace_threads_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "workspace_threads_slug_key" ON "workspace_threads"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "workspace_threads_workspace_id_idx" ON "workspace_threads"("workspace_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "workspace_threads_user_id_idx" ON "workspace_threads"("user_id");
|
|
@ -54,18 +54,19 @@ model system_settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
model users {
|
model users {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String? @unique
|
username String? @unique
|
||||||
password String
|
password String
|
||||||
pfpFilename String?
|
pfpFilename String?
|
||||||
role String @default("default")
|
role String @default("default")
|
||||||
suspended Int @default(0)
|
suspended Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastUpdatedAt DateTime @default(now())
|
lastUpdatedAt DateTime @default(now())
|
||||||
workspace_chats workspace_chats[]
|
workspace_chats workspace_chats[]
|
||||||
workspace_users workspace_users[]
|
workspace_users workspace_users[]
|
||||||
embed_configs embed_configs[]
|
embed_configs embed_configs[]
|
||||||
embed_chats embed_chats[]
|
embed_chats embed_chats[]
|
||||||
|
threads workspace_threads[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model document_vectors {
|
model document_vectors {
|
||||||
|
@ -101,6 +102,22 @@ model workspaces {
|
||||||
documents workspace_documents[]
|
documents workspace_documents[]
|
||||||
workspace_suggested_messages workspace_suggested_messages[]
|
workspace_suggested_messages workspace_suggested_messages[]
|
||||||
embed_configs embed_configs[]
|
embed_configs embed_configs[]
|
||||||
|
threads workspace_threads[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model workspace_threads {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
workspace_id Int
|
||||||
|
user_id Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastUpdatedAt DateTime @default(now())
|
||||||
|
workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
|
||||||
|
user users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([workspace_id])
|
||||||
|
@@index([user_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model workspace_suggested_messages {
|
model workspace_suggested_messages {
|
||||||
|
@ -122,6 +139,7 @@ model workspace_chats {
|
||||||
response String
|
response String
|
||||||
include Boolean @default(true)
|
include Boolean @default(true)
|
||||||
user_id Int?
|
user_id Int?
|
||||||
|
thread_id Int? // No relation to prevent whole table migration
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastUpdatedAt DateTime @default(now())
|
lastUpdatedAt DateTime @default(now())
|
||||||
users users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
users users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
|
@ -1,7 +1,21 @@
|
||||||
const { WorkspaceChats } = require("../../../models/workspaceChats");
|
const { WorkspaceChats } = require("../../../models/workspaceChats");
|
||||||
|
|
||||||
async function resetMemory(workspace, _message, msgUUID, user = null) {
|
async function resetMemory(
|
||||||
await WorkspaceChats.markHistoryInvalid(workspace.id, user);
|
workspace,
|
||||||
|
_message,
|
||||||
|
msgUUID,
|
||||||
|
user = null,
|
||||||
|
thread = null
|
||||||
|
) {
|
||||||
|
// If thread is present we are wanting to reset this specific thread. Not the whole workspace.
|
||||||
|
thread
|
||||||
|
? await WorkspaceChats.markThreadHistoryInvalid(
|
||||||
|
workspace.id,
|
||||||
|
user,
|
||||||
|
thread.id
|
||||||
|
)
|
||||||
|
: await WorkspaceChats.markHistoryInvalid(workspace.id, user);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uuid: msgUUID,
|
uuid: msgUUID,
|
||||||
type: "textResponse",
|
type: "textResponse",
|
||||||
|
|
|
@ -204,6 +204,8 @@ async function chatWithWorkspace(
|
||||||
|
|
||||||
// On query we dont return message history. All other chat modes and when chatting
|
// On query we dont return message history. All other chat modes and when chatting
|
||||||
// with no embeddings we return history.
|
// with no embeddings we return history.
|
||||||
|
// TODO: Refactor to just run a .where on WorkspaceChat to simplify what is going on here.
|
||||||
|
// see recentThreadChatHistory
|
||||||
async function recentChatHistory(
|
async function recentChatHistory(
|
||||||
user = null,
|
user = null,
|
||||||
workspace,
|
workspace,
|
||||||
|
@ -226,6 +228,30 @@ async function recentChatHistory(
|
||||||
return { rawHistory, chatHistory: convertToPromptHistory(rawHistory) };
|
return { rawHistory, chatHistory: convertToPromptHistory(rawHistory) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension of recentChatHistory that supports threads
|
||||||
|
async function recentThreadChatHistory(
|
||||||
|
user = null,
|
||||||
|
workspace,
|
||||||
|
thread,
|
||||||
|
messageLimit = 20,
|
||||||
|
chatMode = null
|
||||||
|
) {
|
||||||
|
if (chatMode === "query") return [];
|
||||||
|
const rawHistory = (
|
||||||
|
await WorkspaceChats.where(
|
||||||
|
{
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
user_id: user?.id || null,
|
||||||
|
thread_id: thread?.id || null,
|
||||||
|
include: true,
|
||||||
|
},
|
||||||
|
messageLimit,
|
||||||
|
{ id: "desc" }
|
||||||
|
)
|
||||||
|
).reverse();
|
||||||
|
return { rawHistory, chatHistory: convertToPromptHistory(rawHistory) };
|
||||||
|
}
|
||||||
|
|
||||||
async function emptyEmbeddingChat({
|
async function emptyEmbeddingChat({
|
||||||
uuid,
|
uuid,
|
||||||
user,
|
user,
|
||||||
|
@ -270,6 +296,7 @@ function chatPrompt(workspace) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
recentChatHistory,
|
recentChatHistory,
|
||||||
|
recentThreadChatHistory,
|
||||||
convertToPromptHistory,
|
convertToPromptHistory,
|
||||||
convertToChatHistory,
|
convertToChatHistory,
|
||||||
chatWithWorkspace,
|
chatWithWorkspace,
|
||||||
|
|
|
@ -6,6 +6,7 @@ const {
|
||||||
recentChatHistory,
|
recentChatHistory,
|
||||||
VALID_COMMANDS,
|
VALID_COMMANDS,
|
||||||
chatPrompt,
|
chatPrompt,
|
||||||
|
recentThreadChatHistory,
|
||||||
} = require(".");
|
} = require(".");
|
||||||
|
|
||||||
const VALID_CHAT_MODE = ["chat", "query"];
|
const VALID_CHAT_MODE = ["chat", "query"];
|
||||||
|
@ -19,13 +20,20 @@ async function streamChatWithWorkspace(
|
||||||
workspace,
|
workspace,
|
||||||
message,
|
message,
|
||||||
chatMode = "chat",
|
chatMode = "chat",
|
||||||
user = null
|
user = null,
|
||||||
|
thread = null
|
||||||
) {
|
) {
|
||||||
const uuid = uuidv4();
|
const uuid = uuidv4();
|
||||||
const command = grepCommand(message);
|
const command = grepCommand(message);
|
||||||
|
|
||||||
if (!!command && Object.keys(VALID_COMMANDS).includes(command)) {
|
if (!!command && Object.keys(VALID_COMMANDS).includes(command)) {
|
||||||
const data = await VALID_COMMANDS[command](workspace, message, uuid, user);
|
const data = await VALID_COMMANDS[command](
|
||||||
|
workspace,
|
||||||
|
message,
|
||||||
|
uuid,
|
||||||
|
user,
|
||||||
|
thread
|
||||||
|
);
|
||||||
writeResponseChunk(response, data);
|
writeResponseChunk(response, data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -65,6 +73,8 @@ async function streamChatWithWorkspace(
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are no embeddings - chat like a normal LLM chat interface.
|
// If there are no embeddings - chat like a normal LLM chat interface.
|
||||||
|
// no need to pass in chat mode - because if we are here we are in
|
||||||
|
// "chat" mode + have embeddings.
|
||||||
return await streamEmptyEmbeddingChat({
|
return await streamEmptyEmbeddingChat({
|
||||||
response,
|
response,
|
||||||
uuid,
|
uuid,
|
||||||
|
@ -73,16 +83,21 @@ async function streamChatWithWorkspace(
|
||||||
workspace,
|
workspace,
|
||||||
messageLimit,
|
messageLimit,
|
||||||
LLMConnector,
|
LLMConnector,
|
||||||
|
thread,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let completeText;
|
let completeText;
|
||||||
const { rawHistory, chatHistory } = await recentChatHistory(
|
const { rawHistory, chatHistory } = thread
|
||||||
user,
|
? await recentThreadChatHistory(
|
||||||
workspace,
|
user,
|
||||||
messageLimit,
|
workspace,
|
||||||
chatMode
|
thread,
|
||||||
);
|
messageLimit,
|
||||||
|
chatMode
|
||||||
|
)
|
||||||
|
: await recentChatHistory(user, workspace, messageLimit, chatMode);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contextTexts = [],
|
contextTexts = [],
|
||||||
sources = [],
|
sources = [],
|
||||||
|
@ -167,6 +182,7 @@ async function streamChatWithWorkspace(
|
||||||
prompt: message,
|
prompt: message,
|
||||||
response: { text: completeText, sources, type: chatMode },
|
response: { text: completeText, sources, type: chatMode },
|
||||||
user,
|
user,
|
||||||
|
threadId: thread?.id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -179,13 +195,12 @@ async function streamEmptyEmbeddingChat({
|
||||||
workspace,
|
workspace,
|
||||||
messageLimit,
|
messageLimit,
|
||||||
LLMConnector,
|
LLMConnector,
|
||||||
|
thread = null,
|
||||||
}) {
|
}) {
|
||||||
let completeText;
|
let completeText;
|
||||||
const { rawHistory, chatHistory } = await recentChatHistory(
|
const { rawHistory, chatHistory } = thread
|
||||||
user,
|
? await recentThreadChatHistory(user, workspace, thread, messageLimit)
|
||||||
workspace,
|
: await recentChatHistory(user, workspace, messageLimit);
|
||||||
messageLimit
|
|
||||||
);
|
|
||||||
|
|
||||||
// If streaming is not explicitly enabled for connector
|
// If streaming is not explicitly enabled for connector
|
||||||
// we do regular waiting of a response and send a single chunk.
|
// we do regular waiting of a response and send a single chunk.
|
||||||
|
@ -225,6 +240,7 @@ async function streamEmptyEmbeddingChat({
|
||||||
prompt: message,
|
prompt: message,
|
||||||
response: { text: completeText, sources: [], type: "chat" },
|
response: { text: completeText, sources: [], type: "chat" },
|
||||||
user,
|
user,
|
||||||
|
threadId: thread?.id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
52
server/utils/middleware/validWorkspace.js
Normal file
52
server/utils/middleware/validWorkspace.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
const { Workspace } = require("../../models/workspace");
|
||||||
|
const { WorkspaceThread } = require("../../models/workspaceThread");
|
||||||
|
const { userFromSession, multiUserMode } = require("../http");
|
||||||
|
|
||||||
|
// Will pre-validate and set the workspace for a request if the slug is provided in the URL path.
|
||||||
|
async function validWorkspaceSlug(request, response, next) {
|
||||||
|
const { slug } = request.params;
|
||||||
|
const user = await userFromSession(request, response);
|
||||||
|
const workspace = multiUserMode(response)
|
||||||
|
? await Workspace.getWithUser(user, { slug })
|
||||||
|
: await Workspace.get({ slug });
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
response.status(404).send("Workspace does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.locals.workspace = workspace;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will pre-validate and set the workspace AND a thread for a request if the slugs are provided in the URL path.
|
||||||
|
async function validWorkspaceAndThreadSlug(request, response, next) {
|
||||||
|
const { slug, threadSlug } = request.params;
|
||||||
|
const user = await userFromSession(request, response);
|
||||||
|
const workspace = multiUserMode(response)
|
||||||
|
? await Workspace.getWithUser(user, { slug })
|
||||||
|
: await Workspace.get({ slug });
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
response.status(404).send("Workspace does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = await WorkspaceThread.get({
|
||||||
|
slug: threadSlug,
|
||||||
|
user_id: user?.id || null,
|
||||||
|
});
|
||||||
|
if (!thread) {
|
||||||
|
response.status(404).send("Workspace thread does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.locals.workspace = workspace;
|
||||||
|
response.locals.thread = thread;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validWorkspaceSlug,
|
||||||
|
validWorkspaceAndThreadSlug,
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user