[FEAT] Ability to set workspace profile image ()

* 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>
This commit is contained in:
Sean Hatfield 2024-03-12 11:36:59 -07:00 committed by GitHub
parent d9fce5f65e
commit 04399b1328
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 357 additions and 28 deletions
frontend/src
components/WorkspaceChat/ChatContainer/ChatHistory
HistoricalMessage
PromptReply
models
pages
WorkspaceChat
WorkspaceSettings/GeneralAppearance
SuggestedChatMessages
WorkspacePfp
index.jsx
server
endpoints
models
prisma
migrations/20240301002308_init
schema.prisma
utils/files

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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);
}

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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} />
</>
);

View file

@ -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,

View file

@ -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 };

View file

@ -19,6 +19,7 @@ const Workspace = {
"chatModel",
"topN",
"chatMode",
"pfpFilename",
],
new: async function (name = null, creatorId = null) {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "pfpFilename" TEXT;

View file

@ -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[]

View file

@ -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,
};