mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2025-05-02 17:07:13 +00:00
[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>
This commit is contained in:
parent
d9fce5f65e
commit
04399b1328
13 changed files with 357 additions and 28 deletions
frontend/src
components/WorkspaceChat/ChatContainer/ChatHistory
models
pages
server
endpoints
models
prisma
utils/files
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -19,6 +19,7 @@ const Workspace = {
|
|||
"chatModel",
|
||||
"topN",
|
||||
"chatMode",
|
||||
"pfpFilename",
|
||||
],
|
||||
|
||||
new: async function (name = null, creatorId = null) {
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ADD COLUMN "pfpFilename" TEXT;
|
|
@ -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[]
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue