diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index 1a3f1a0db5dc1d5699363412219c5255e44544b6..a128e0195b6034e6d9a8c94840343bccb2c6780a 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 440883a7167665056830501f15b9e88d45b0086d..5c48e92246eed615ff2c18efb94b04b0326626eb 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 d003887433d2521248a0a68345331db66d2ad87d..6786abffd9bb05eed051c2ad4ac930ae9f740f99 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 88a6744cdfae3360348b121b5bab7da6f8760d11..6d6ce4b4b24efa641187e2951ccc96804882eed6 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 05cdbebac401e3899c823b30f540d7c04e993cb8..5ac15f218af9b611fce3b112baf765dc82572613 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 0000000000000000000000000000000000000000..e9fb8303a544e7a94eaaf264fe18867abc32a3f4 --- /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 ee00143e44a0d89b1bada9318d0f43d0d818da5b..b6d5b84a6a223cfcb4cf1383f121b2b73958aee0 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 a36777c8f5c74af59c77b47756d738a8ba3d4e15..74b83688e8231e58bec9ff995d4d6526441a3544 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 54228bba0e59b7cd7ae29a85a7a5f6f72c6d2331..2fe63e58af92f9ca6a088e65af906d26919dd316 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 92c2f9e36257604317a464b7b6b5b4579dd788d2..48952c637c620ecfae4436efabe7a6b0315985e3 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 0000000000000000000000000000000000000000..5847beafd581ecf06181e51c6ffc7ddd15f49ab5 --- /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 8cd3a1d343d4799335d4aca9ceedc900ef94b6b3..e6121e2977e2905ef8a663df15c20d105bbe76f4 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 dd6ba0fe2d1c47ef4558d8e6066294bbfc751c8a..0d1dd9f856051d16b7a2d5acce18337f5fbcb8ae 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, };