Admin Embed Chats mgmt page

This commit is contained in:
timothycarambat 2024-02-02 11:41:04 -08:00
parent b219c5df0e
commit 8e0b08ecad
7 changed files with 312 additions and 14 deletions

View File

@ -44,7 +44,7 @@ const DataConnectorSetup = lazy(
const EmbedConfigSetup = lazy(
() => import("@/pages/GeneralSettings/EmbedConfigs")
);
const EmbedChats = lazy(() => import("@/pages/Admin/Users"));
const EmbedChats = lazy(() => import("@/pages/GeneralSettings/EmbedChats"));
export default function App() {
return (

View File

@ -52,6 +52,29 @@ const Embed = {
return { success: true, error: e.message };
});
},
chats: async (offset = 0) => {
return await fetch(`${API_BASE}/embed/chats`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify({ offset }),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return [];
});
},
deleteChat: async (chatId) => {
return await fetch(`${API_BASE}/embed/chats/${chatId}`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => {
console.error(e);
return { success: false, error: e.message };
});
},
};
export default Embed;

View File

@ -0,0 +1,130 @@
import { useRef } from "react";
import truncate from "truncate";
import { X, Trash, LinkSimple } from "@phosphor-icons/react";
import ModalWrapper from "@/components/ModalWrapper";
import { useModal } from "@/hooks/useModal";
import paths from "@/utils/paths";
import Embed from "@/models/embed";
export default function ChatRow({ chat }) {
const rowRef = useRef(null);
const {
isOpen: isPromptOpen,
openModal: openPromptModal,
closeModal: closePromptModal,
} = useModal();
const {
isOpen: isResponseOpen,
openModal: openResponseModal,
closeModal: closeResponseModal,
} = useModal();
const handleDelete = async () => {
if (
!window.confirm(
`Are you sure you want to delete this chat?\n\nThis action is irreversible.`
)
)
return false;
rowRef?.current?.remove();
await Embed.deleteChat(chat.id);
};
return (
<>
<tr
ref={rowRef}
className="bg-transparent text-white text-opacity-80 text-sm font-medium"
>
<td className="px-6 py-4 font-medium whitespace-nowrap text-white">
<a
href={paths.settings.embedSetup()}
target="_blank"
rel="noreferrer"
className="text-white flex items-center hover:underline"
>
<LinkSimple className="mr-2 w-5 h-5" />{" "}
{chat.embed_config.workspace.name}
</a>
</td>
<td className="px-6 py-4 font-medium whitespace-nowrap text-white">
<div className="flex flex-col">
<p>{truncate(chat.session_id, 20)}</p>
<ConnectionDetails
connection_information={chat.connection_information}
/>
</div>
</td>
<td
onClick={openPromptModal}
className="px-6 py-4 border-transparent cursor-pointer transform transition-transform duration-200 hover:scale-105 hover:shadow-lg"
>
{truncate(chat.prompt, 40)}
</td>
<td
onClick={openResponseModal}
className="px-6 py-4 cursor-pointer transform transition-transform duration-200 hover:scale-105 hover:shadow-lg"
>
{truncate(JSON.parse(chat.response)?.text, 40)}
</td>
<td className="px-6 py-4">{chat.createdAt}</td>
<td className="px-6 py-4 flex items-center gap-x-6">
<button
onClick={handleDelete}
className="font-medium text-red-300 px-2 py-1 rounded-lg hover:bg-red-800 hover:bg-opacity-20"
>
<Trash className="h-5 w-5" />
</button>
</td>
</tr>
<ModalWrapper isOpen={isPromptOpen}>
<TextPreview text={chat.prompt} closeModal={closePromptModal} />
</ModalWrapper>
<ModalWrapper isOpen={isResponseOpen}>
<TextPreview
text={JSON.parse(chat.response)?.text}
closeModal={closeResponseModal}
/>
</ModalWrapper>
</>
);
}
const TextPreview = ({ text, closeModal }) => {
return (
<div className="relative w-full md:max-w-2xl max-h-full">
<div className="relative bg-main-gradient rounded-lg shadow">
<div className="flex items-start justify-between p-4 border-b rounded-t border-gray-600">
<h3 className="text-xl font-semibold text-white">Viewing Text</h3>
<button
onClick={closeModal}
type="button"
className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<X className="text-gray-300 text-lg" />
</button>
</div>
<div className="w-full p-6">
<pre className="w-full h-[200px] py-2 px-4 whitespace-pre-line overflow-auto rounded-lg bg-zinc-900 border border-gray-500 text-white text-sm">
{text}
</pre>
</div>
</div>
</div>
);
};
const ConnectionDetails = ({ connection_information }) => {
let details = {};
try {
details = JSON.parse(connection_information);
} catch {}
if (Object.keys(details).length === 0) return null;
return (
<>
{details.ip && <p className="text-xs text-slate-400">{details.ip}</p>}
{details.host && <p className="text-xs text-slate-400">{details.host}</p>}
</>
);
};

View File

@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import Sidebar, { SidebarMobileHeader } from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import * as Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import useQuery from "@/hooks/useQuery";
import ChatRow from "./ChatRow";
import Embed from "@/models/embed";
export default function EmbedChats() {
// TODO: Add export of embed chats
return (
<div className="w-screen h-screen overflow-hidden bg-sidebar flex">
{!isMobile && <Sidebar />}
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
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"
>
{isMobile && <SidebarMobileHeader />}
<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">Embed Chats</p>
</div>
<p className="text-sm font-base text-white text-opacity-60">
These are all the recorded chats and messages from any embed that
you have published.
</p>
</div>
<ChatsContainer />
</div>
</div>
</div>
);
}
function ChatsContainer() {
const query = useQuery();
const [loading, setLoading] = useState(true);
const [chats, setChats] = useState([]);
const [offset, setOffset] = useState(Number(query.get("offset") || 0));
const [canNext, setCanNext] = useState(false);
const handlePrevious = () => {
setOffset(Math.max(offset - 1, 0));
};
const handleNext = () => {
setOffset(offset + 1);
};
useEffect(() => {
async function fetchChats() {
const { chats: _chats, hasPages = false } = await Embed.chats(offset);
setChats(_chats);
setCanNext(hasPages);
setLoading(false);
}
fetchChats();
}, [offset]);
if (loading) {
return (
<Skeleton.default
height="80vh"
width="100%"
highlightColor="#3D4147"
baseColor="#2C2F35"
count={1}
className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6"
containerClassName="flex w-full"
/>
);
}
return (
<>
<table className="w-full text-sm text-left rounded-lg mt-5">
<thead className="text-white text-opacity-80 text-sm font-bold uppercase border-white border-b border-opacity-60">
<tr>
<th scope="col" className="px-6 py-3 rounded-tl-lg">
Embed
</th>
<th scope="col" className="px-6 py-3">
Sender
</th>
<th scope="col" className="px-6 py-3">
Message
</th>
<th scope="col" className="px-6 py-3">
Response
</th>
<th scope="col" className="px-6 py-3">
Sent At
</th>
<th scope="col" className="px-6 py-3 rounded-tr-lg">
{" "}
</th>
</tr>
</thead>
<tbody>
{!!chats &&
chats.map((chat) => <ChatRow key={chat.id} chat={chat} />)}
</tbody>
</table>
<div className="flex w-full justify-between items-center">
<button
onClick={handlePrevious}
className="px-4 py-2 rounded-lg border border-slate-200 text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 disabled:invisible"
disabled={offset === 0}
>
{" "}
Previous Page
</button>
<button
onClick={handleNext}
className="px-4 py-2 rounded-lg border border-slate-200 text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 disabled:invisible"
disabled={!canNext}
>
Next Page
</button>
</div>
</>
);
}

View File

@ -1,10 +1,7 @@
const { EmbedChats } = require("../models/embedChats");
const { EmbedConfig } = require("../models/embedConfig");
const { reqBody, userFromSession } = require("../utils/http");
const {
validEmbedConfig,
validEmbedConfigId,
} = require("../utils/middleware/embedMiddleware");
const { validEmbedConfigId } = require("../utils/middleware/embedMiddleware");
const {
flexUserRoleValid,
ROLES,
@ -80,14 +77,14 @@ function embedManagementEndpoints(app) {
app.post(
"/embed/chats",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_, response) => {
async (request, response) => {
try {
const { offset = 0, limit = 20 } = reqBody(request);
const embedChats = await EmbedChats.whereWithEmbed(
const embedChats = await EmbedChats.whereWithEmbedAndWorkspace(
{},
limit,
offset * limit,
{ id: "desc" }
{ id: "desc" },
offset * limit
);
const totalChats = await EmbedChats.count();
const hasPages = totalChats > (offset + 1) * limit;
@ -98,6 +95,21 @@ function embedManagementEndpoints(app) {
}
}
);
app.delete(
"/embed/chats/:chatId",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { chatId } = request.params;
await EmbedChats.delete({ id: Number(chatId) });
response.status(200).json({ success: true, error: null });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
}
module.exports = { embedManagementEndpoints };

View File

@ -115,7 +115,7 @@ const EmbedChats = {
}
},
whereWithEmbed: async function (
whereWithEmbedAndWorkspace: async function (
clause = {},
limit = null,
orderBy = null,
@ -124,7 +124,17 @@ const EmbedChats = {
try {
const chats = await prisma.embed_chats.findMany({
where: clause,
include: { embed_config: true },
include: {
embed_config: {
select: {
workspace: {
select: {
name: true,
},
},
},
},
},
...(limit !== null ? { take: limit } : {}),
...(offset !== null ? { skip: offset } : {}),
...(orderBy !== null ? { orderBy } : {}),

View File

@ -21,9 +21,8 @@ async function validEmbedConfig(request, response, next) {
function setConnectionMeta(request, response, next) {
response.locals.connection = {
host: request.hostname,
path: request.path,
ip: request.ip,
host: request.headers?.origin,
ip: request?.ip,
};
next();
}