From 04399b13280fba821e2f4247ed32245a090f88bd Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Tue, 12 Mar 2024 11:36:59 -0700 Subject: [PATCH] [FEAT] Ability to set workspace profile image (#847) * WIP workspace pfp, CRUD functions complete * implement fetching workspace pfp in UserIcon component * update UI for workspace settings pfp * minor css refactor * WIP fixes to workspace pfp * create responseCache for workspace pfp blob to improve performance * fix cache not clearing when removing workspace pfp and remove unneeded util * load workspace image once, dont reload --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- .../ChatHistory/HistoricalMessage/index.jsx | 34 +++-- .../ChatHistory/PromptReply/index.jsx | 31 ++-- frontend/src/models/workspace.js | 48 ++++++ frontend/src/pages/WorkspaceChat/index.jsx | 4 +- .../SuggestedChatMessages/index.jsx | 2 +- .../GeneralAppearance/WorkspacePfp/index.jsx | 96 ++++++++++++ .../GeneralAppearance/index.jsx | 6 +- server/endpoints/system.js | 2 - server/endpoints/workspaces.js | 143 ++++++++++++++++++ server/models/workspace.js | 1 + .../20240301002308_init/migration.sql | 2 + server/prisma/schema.prisma | 1 + server/utils/files/pfp.js | 15 ++ 13 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspacePfp/index.jsx create mode 100644 server/prisma/migrations/20240301002308_init/migration.sql diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index 1a3f1a0db..a128e0195 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -31,15 +31,7 @@ const HistoricalMessage = ({ className={`py-8 px-4 w-full flex gap-x-5 md:max-w-[800px] flex-col`} > <div className="flex gap-x-5"> - <Jazzicon - size={36} - user={{ - uid: - role === "user" ? userFromStorage()?.username : workspace.slug, - }} - role={role} - /> - + <ProfileImage role={role} workspace={workspace} /> {error ? ( <div className="p-2 rounded-lg bg-red-50 text-red-500"> <span className={`inline-block `}> @@ -76,4 +68,28 @@ const HistoricalMessage = ({ ); }; +function ProfileImage({ role, workspace }) { + if (role === "assistant" && workspace.pfpUrl) { + return ( + <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden"> + <img + src={workspace.pfpUrl} + alt="Workspace profile picture" + className="absolute top-0 left-0 w-full h-full object-cover rounded-full bg-white" + /> + </div> + ); + } + + return ( + <Jazzicon + size={36} + user={{ + uid: role === "user" ? userFromStorage()?.username : workspace.slug, + }} + role={role} + /> + ); +} + export default memo(HistoricalMessage); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx index 440883a71..5c48e9224 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx @@ -14,7 +14,6 @@ const PromptReply = ({ closed = true, }) => { const assistantBackgroundColor = "bg-historical-msg-system"; - if (!reply && sources.length === 0 && !pending && !error) return null; if (pending) { @@ -24,11 +23,7 @@ const PromptReply = ({ > <div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[800px] flex-col"> <div className="flex gap-x-5"> - <Jazzicon - size={36} - user={{ uid: workspace.slug }} - role="assistant" - /> + <WorkspaceProfileImage workspace={workspace} /> <div className="mt-3 ml-5 dot-falling"></div> </div> </div> @@ -43,11 +38,7 @@ const PromptReply = ({ > <div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[800px] flex-col"> <div className="flex gap-x-5"> - <Jazzicon - size={36} - user={{ uid: workspace.slug }} - role="assistant" - /> + <WorkspaceProfileImage workspace={workspace} /> <span className={`inline-block p-2 rounded-lg bg-red-50 text-red-500`} > @@ -68,7 +59,7 @@ const PromptReply = ({ > <div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[800px] flex-col"> <div className="flex gap-x-5"> - <Jazzicon size={36} user={{ uid: workspace.slug }} role="assistant" /> + <WorkspaceProfileImage workspace={workspace} /> <span className={`reply flex flex-col gap-y-1 mt-2`} dangerouslySetInnerHTML={{ __html: renderMarkdown(reply) }} @@ -80,4 +71,20 @@ const PromptReply = ({ ); }; +function WorkspaceProfileImage({ workspace }) { + if (!!workspace.pfpUrl) { + return ( + <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden"> + <img + src={workspace.pfpUrl} + alt="Workspace profile picture" + className="absolute top-0 left-0 w-full h-full object-cover rounded-full bg-white" + /> + </div> + ); + } + + return <Jazzicon size={36} user={{ uid: workspace.slug }} role="assistant" />; +} + export default memo(PromptReply); diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index d00388743..6786abffd 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -238,6 +238,54 @@ const Workspace = { }); }, threads: WorkspaceThread, + + uploadPfp: async function (formData, slug) { + return await fetch(`${API_BASE}/workspace/${slug}/upload-pfp`, { + method: "POST", + body: formData, + headers: baseHeaders(), + }) + .then((res) => { + if (!res.ok) throw new Error("Error uploading pfp."); + return { success: true, error: null }; + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, + + fetchPfp: async function (slug) { + return await fetch(`${API_BASE}/workspace/${slug}/pfp`, { + method: "GET", + cache: "no-cache", + headers: baseHeaders(), + }) + .then((res) => { + if (res.ok && res.status !== 204) return res.blob(); + throw new Error("Failed to fetch pfp."); + }) + .then((blob) => (blob ? URL.createObjectURL(blob) : null)) + .catch((e) => { + console.log(e); + return null; + }); + }, + + removePfp: async function (slug) { + return await fetch(`${API_BASE}/workspace/${slug}/remove-pfp`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => { + if (res.ok) return { success: true, error: null }; + throw new Error("Failed to remove pfp."); + }) + .catch((e) => { + console.log(e); + return { success: false, error: e.message }; + }); + }, }; export default Workspace; diff --git a/frontend/src/pages/WorkspaceChat/index.jsx b/frontend/src/pages/WorkspaceChat/index.jsx index 88a6744cd..6d6ce4b4b 100644 --- a/frontend/src/pages/WorkspaceChat/index.jsx +++ b/frontend/src/pages/WorkspaceChat/index.jsx @@ -19,7 +19,7 @@ export default function WorkspaceChat() { } function ShowWorkspaceChat() { - const { slug, threadSlug = null } = useParams(); + const { slug } = useParams(); const [workspace, setWorkspace] = useState(null); const [loading, setLoading] = useState(true); @@ -32,9 +32,11 @@ function ShowWorkspaceChat() { return; } const suggestedMessages = await Workspace.getSuggestedMessages(slug); + const pfpUrl = await Workspace.fetchPfp(slug); setWorkspace({ ..._workspace, suggestedMessages, + pfpUrl, }); setLoading(false); } diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/SuggestedChatMessages/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/SuggestedChatMessages/index.jsx index 05cdbebac..5ac15f218 100644 --- a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/SuggestedChatMessages/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/SuggestedChatMessages/index.jsx @@ -101,7 +101,7 @@ export default function SuggestedChatMessages({ slug }) { </div> ); return ( - <div className="w-screen"> + <div className="w-screen mt-6"> <div className="flex flex-col"> <label className="block input-label">Suggested Chat Messages</label> <p className="text-white text-opacity-60 text-xs font-medium py-1.5"> diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspacePfp/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspacePfp/index.jsx new file mode 100644 index 000000000..e9fb8303a --- /dev/null +++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspacePfp/index.jsx @@ -0,0 +1,96 @@ +import Workspace from "@/models/workspace"; +import showToast from "@/utils/toast"; +import { Plus } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; + +export default function WorkspacePfp({ workspace, slug }) { + const [pfp, setPfp] = useState(null); + + useEffect(() => { + async function fetchWorkspace() { + const pfpUrl = await Workspace.fetchPfp(slug); + setPfp(pfpUrl); + } + fetchWorkspace(); + }, [slug]); + + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file) return false; + + const formData = new FormData(); + formData.append("file", file); + const { success, error } = await Workspace.uploadPfp( + formData, + workspace.slug + ); + if (!success) { + showToast(`Failed to upload profile picture: ${error}`, "error"); + return; + } + + const pfpUrl = await Workspace.fetchPfp(workspace.slug); + setPfp(pfpUrl); + showToast("Profile picture uploaded.", "success"); + }; + + const handleRemovePfp = async () => { + const { success, error } = await Workspace.removePfp(workspace.slug); + if (!success) { + showToast(`Failed to remove profile picture: ${error}`, "error"); + return; + } + + setPfp(null); + }; + + return ( + <div className="mt-6"> + <div className="flex flex-col"> + <label className="block input-label">Assistant Profile Image</label> + <p className="text-white text-opacity-60 text-xs font-medium py-1.5"> + Customize the profile image of the assistant for this workspace. + </p> + </div> + <div className="flex flex-col md:flex-row items-center gap-8"> + <div className="flex flex-col items-center"> + <label className="w-36 h-36 flex flex-col items-center justify-center bg-zinc-900/50 transition-all duration-300 rounded-full mt-8 border-2 border-dashed border-white border-opacity-60 cursor-pointer hover:opacity-60"> + <input + id="workspace-pfp-upload" + type="file" + accept="image/*" + className="hidden" + onChange={handleFileUpload} + /> + {pfp ? ( + <img + src={pfp} + alt="User profile picture" + className="w-36 h-36 rounded-full object-cover bg-white" + /> + ) : ( + <div className="flex flex-col items-center justify-center p-3"> + <Plus className="w-8 h-8 text-white/80 m-2" /> + <span className="text-white text-opacity-80 text-xs font-semibold"> + Workspace Image + </span> + <span className="text-white text-opacity-60 text-xs"> + 800 x 800 + </span> + </div> + )} + </label> + {pfp && ( + <button + type="button" + onClick={handleRemovePfp} + className="mt-3 text-white text-opacity-60 text-sm font-medium hover:underline" + > + Remove Workspace Image + </button> + )} + </div> + </div> + </div> + ); +} diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx index ee00143e4..b6d5b84a6 100644 --- a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx @@ -6,6 +6,7 @@ import VectorCount from "./VectorCount"; import WorkspaceName from "./WorkspaceName"; import SuggestedChatMessages from "./SuggestedChatMessages"; import DeleteWorkspace from "./DeleteWorkspace"; +import WorkspacePfp from "./WorkspacePfp"; export default function GeneralInfo({ slug }) { const [workspace, setWorkspace] = useState(null); @@ -66,9 +67,8 @@ export default function GeneralInfo({ slug }) { </button> )} </form> - <div className="mt-6"> - <SuggestedChatMessages slug={workspace.slug} /> - </div> + <SuggestedChatMessages slug={workspace.slug} /> + <WorkspacePfp workspace={workspace} slug={slug} /> <DeleteWorkspace workspace={workspace} /> </> ); diff --git a/server/endpoints/system.js b/server/endpoints/system.js index a36777c8f..74b83688e 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -548,8 +548,6 @@ function systemEndpoints(app) { const userRecord = await User.get({ id: user.id }); const oldPfpFilename = userRecord.pfpFilename; - - console.log("oldPfpFilename", oldPfpFilename); if (oldPfpFilename) { const oldPfpPath = path.join( __dirname, diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 54228bba0..2fe63e58a 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -19,10 +19,21 @@ const { validWorkspaceSlug } = require("../utils/middleware/validWorkspace"); const { convertToChatHistory } = require("../utils/helpers/chat/responses"); const { CollectorApi } = require("../utils/collectorApi"); const { handleUploads } = setupMulter(); +const { setupPfpUploads } = require("../utils/files/multer"); +const { normalizePath } = require("../utils/files"); +const { handlePfpUploads } = setupPfpUploads(); +const path = require("path"); +const fs = require("fs"); +const { + determineWorkspacePfpFilepath, + fetchPfp, +} = require("../utils/files/pfp"); function workspaceEndpoints(app) { if (!app) return; + const responseCache = new Map(); + app.post( "/workspace/new", [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], @@ -422,6 +433,138 @@ function workspaceEndpoints(app) { } } ); + + app.get( + "/workspace/:slug/pfp", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async function (request, response) { + try { + const { slug } = request.params; + const cachedResponse = responseCache.get(slug); + + if (cachedResponse) { + response.writeHead(200, { + "Content-Type": cachedResponse.mime || "image/png", + }); + response.end(cachedResponse.buffer); + return; + } + + const pfpPath = await determineWorkspacePfpFilepath(slug); + + if (!pfpPath) { + response.sendStatus(204).end(); + return; + } + + const { found, buffer, mime } = fetchPfp(pfpPath); + if (!found) { + response.sendStatus(204).end(); + return; + } + + responseCache.set(slug, { buffer, mime }); + + response.writeHead(200, { + "Content-Type": mime || "image/png", + }); + response.end(buffer); + return; + } catch (error) { + console.error("Error processing the logo request:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.post( + "/workspace/:slug/upload-pfp", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + handlePfpUploads.single("file"), + async function (request, response) { + try { + const { slug } = request.params; + const uploadedFileName = request.randomFileName; + if (!uploadedFileName) { + return response.status(400).json({ message: "File upload failed." }); + } + + const workspaceRecord = await Workspace.get({ + slug, + }); + + const oldPfpFilename = workspaceRecord.pfpFilename; + if (oldPfpFilename) { + const oldPfpPath = path.join( + __dirname, + `../storage/assets/pfp/${normalizePath( + workspaceRecord.pfpFilename + )}` + ); + + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { workspace, message } = await Workspace.update( + workspaceRecord.id, + { + pfpFilename: uploadedFileName, + } + ); + + return response.status(workspace ? 200 : 500).json({ + message: workspace + ? "Profile picture uploaded successfully." + : message, + }); + } catch (error) { + console.error("Error processing the profile picture upload:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.delete( + "/workspace/:slug/remove-pfp", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async function (request, response) { + try { + const { slug } = request.params; + const workspaceRecord = await Workspace.get({ + slug, + }); + const oldPfpFilename = workspaceRecord.pfpFilename; + + if (oldPfpFilename) { + const oldPfpPath = path.join( + __dirname, + `../storage/assets/pfp/${normalizePath(oldPfpFilename)}` + ); + + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { workspace, message } = await Workspace.update( + workspaceRecord.id, + { + pfpFilename: null, + } + ); + + // Clear the cache + responseCache.delete(slug); + + return response.status(workspace ? 200 : 500).json({ + message: workspace + ? "Profile picture removed successfully." + : message, + }); + } catch (error) { + console.error("Error processing the profile picture removal:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); } module.exports = { workspaceEndpoints }; diff --git a/server/models/workspace.js b/server/models/workspace.js index 92c2f9e36..48952c637 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -19,6 +19,7 @@ const Workspace = { "chatModel", "topN", "chatMode", + "pfpFilename", ], new: async function (name = null, creatorId = null) { diff --git a/server/prisma/migrations/20240301002308_init/migration.sql b/server/prisma/migrations/20240301002308_init/migration.sql new file mode 100644 index 000000000..5847beafd --- /dev/null +++ b/server/prisma/migrations/20240301002308_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "pfpFilename" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8cd3a1d34..e6121e297 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -100,6 +100,7 @@ model workspaces { chatModel String? topN Int? @default(4) chatMode String? @default("chat") + pfpFilename String? workspace_users workspace_users[] documents workspace_documents[] workspace_suggested_messages workspace_suggested_messages[] diff --git a/server/utils/files/pfp.js b/server/utils/files/pfp.js index dd6ba0fe2..0d1dd9f85 100644 --- a/server/utils/files/pfp.js +++ b/server/utils/files/pfp.js @@ -3,6 +3,7 @@ const fs = require("fs"); const { getType } = require("mime"); const { User } = require("../../models/user"); const { normalizePath } = require("."); +const { Workspace } = require("../../models/workspace"); function fetchPfp(pfpPath) { if (!fs.existsSync(pfpPath)) { @@ -38,7 +39,21 @@ async function determinePfpFilepath(id) { return pfpFilepath; } +async function determineWorkspacePfpFilepath(slug) { + const workspace = await Workspace.get({ slug }); + const pfpFilename = workspace?.pfpFilename || null; + if (!pfpFilename) return null; + + const basePath = process.env.STORAGE_DIR + ? path.join(process.env.STORAGE_DIR, "assets/pfp") + : path.join(__dirname, "../../storage/assets/pfp"); + const pfpFilepath = path.join(basePath, normalizePath(pfpFilename)); + if (!fs.existsSync(pfpFilepath)) return null; + return pfpFilepath; +} + module.exports = { fetchPfp, determinePfpFilepath, + determineWorkspacePfpFilepath, }; -- GitLab