From 608f28d7455a64e6521fc2522a158fa398ab474d Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Tue, 6 Feb 2024 11:24:33 -0800 Subject: [PATCH] [FEAT] create custom prompt suggestions per workspace (#664) * create custom suggested chat messages per workspace * update how suggestedChats are passed to chat window * update mobile styles * update edit change handler --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- frontend/src/App.jsx | 5 + .../Modals/MangeWorkspace/Settings/index.jsx | 8 + .../ChatContainer/ChatHistory/index.jsx | 54 +++-- .../WorkspaceChat/ChatContainer/index.jsx | 6 +- frontend/src/models/workspace.js | 36 +++ frontend/src/pages/WorkspaceChat/index.jsx | 6 +- .../src/pages/WorkspaceSettings/index.jsx | 208 ++++++++++++++++++ frontend/src/utils/paths.js | 3 + server/endpoints/workspaces.js | 50 +++++ server/models/workspacesSuggestedMessages.js | 83 +++++++ .../20240206181106_init/migration.sql | 13 ++ server/prisma/schema.prisma | 43 ++-- 12 files changed, 483 insertions(+), 32 deletions(-) create mode 100644 frontend/src/pages/WorkspaceSettings/index.jsx create mode 100644 server/models/workspacesSuggestedMessages.js create mode 100644 server/prisma/migrations/20240206181106_init/migration.sql diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0c6bfaf43..a08365125 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -41,6 +41,7 @@ const DataConnectors = lazy( const DataConnectorSetup = lazy( () => import("@/pages/GeneralSettings/DataConnectors/Connectors") ); +const WorkspaceSettings = lazy(() => import("@/pages/WorkspaceSettings")); const EmbedConfigSetup = lazy( () => import("@/pages/GeneralSettings/EmbedConfigs") ); @@ -62,6 +63,10 @@ export default function App() { <Route path="/accept-invite/:code" element={<InvitePage />} /> {/* Admin */} + <Route + path="/workspace/:slug/settings" + element={<PrivateRoute Component={WorkspaceSettings} />} + /> <Route path="/settings/llm-preference" element={<AdminRoute Component={GeneralLLMPreference} />} diff --git a/frontend/src/components/Modals/MangeWorkspace/Settings/index.jsx b/frontend/src/components/Modals/MangeWorkspace/Settings/index.jsx index a9471388f..48a3ff5d7 100644 --- a/frontend/src/components/Modals/MangeWorkspace/Settings/index.jsx +++ b/frontend/src/components/Modals/MangeWorkspace/Settings/index.jsx @@ -7,6 +7,7 @@ import PreLoader from "../../../Preloader"; import { useParams } from "react-router-dom"; import showToast from "../../../../utils/toast"; import ChatModelPreference from "./ChatModelPreference"; +import { Link } from "react-router-dom"; // Ensure that a type is correct before sending the body // to the backend. @@ -313,6 +314,13 @@ export default function WorkspaceSettings({ active, workspace, settings }) { </option> </select> </div> + <div className="mt-4 w-full flex justify-start"> + <Link to={paths.workspace.additionalSettings(workspace.slug)}> + <a className="underline text-white/60 text-sm font-medium hover:text-sky-600"> + View additional settings + </a> + </Link> + </div> </div> </div> </div> diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx index 358e520a1..74c159f4a 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx @@ -6,7 +6,7 @@ import ManageWorkspace from "../../../Modals/MangeWorkspace"; import { ArrowDown } from "@phosphor-icons/react"; import debounce from "lodash.debounce"; -export default function ChatHistory({ history = [], workspace }) { +export default function ChatHistory({ history = [], workspace, sendCommand }) { const replyRef = useRef(null); const { showing, showModal, hideModal } = useManageWorkspaceModal(); const [isAtBottom, setIsAtBottom] = useState(true); @@ -46,25 +46,31 @@ export default function ChatHistory({ history = [], workspace }) { } }; + const handleSendSuggestedMessage = (heading, message) => { + sendCommand(`${heading} ${message}`, true); + }; + if (history.length === 0) { return ( - <div className="flex flex-col h-full md:mt-0 pb-48 w-full justify-end items-center"> - <div className="flex flex-col items-start"> + <div className="flex flex-col h-full md:mt-0 pb-44 md:pb-40 w-full justify-end items-center"> + <div className="flex flex-col items-center md:items-start md:max-w-[600px] w-full px-4"> <p className="text-white/60 text-lg font-base py-4"> Welcome to your new workspace. </p> - <div className="w-full text-center"> - <p className="text-white/60 text-lg font-base inline-grid md:inline-flex items-center gap-x-2"> - To get started either{" "} - <span - className="underline font-medium cursor-pointer" - onClick={showModal} - > - upload a document - </span> - or <b className="font-medium italic">send a chat.</b> - </p> - </div> + <p className="w-full items-center text-white/60 text-lg font-base flex flex-col md:flex-row gap-x-1"> + To get started either{" "} + <span + className="underline font-medium cursor-pointer" + onClick={showModal} + > + upload a document + </span> + or <b className="font-medium italic">send a chat.</b> + </p> + <WorkspaceChatSuggestions + suggestions={workspace?.suggestedMessages ?? []} + sendSuggestion={handleSendSuggestedMessage} + /> </div> {showing && ( <ManageWorkspace @@ -134,3 +140,21 @@ export default function ChatHistory({ history = [], workspace }) { </div> ); } + +function WorkspaceChatSuggestions({ suggestions = [], sendSuggestion }) { + if (suggestions.length === 0) return null; + return ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-white/60 text-xs mt-10 w-full justify-center"> + {suggestions.map((suggestion, index) => ( + <button + key={index} + className="text-left p-2.5 border rounded-xl border-white/20 bg-sidebar hover:bg-workspace-item-selected-gradient" + onClick={() => sendSuggestion(suggestion.heading, suggestion.message)} + > + <p className="font-semibold">{suggestion.heading}</p> + <p>{suggestion.message}</p> + </button> + ))} + </div> + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx index 372c79a7c..7a5a974af 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx @@ -97,7 +97,11 @@ export default function ChatContainer({ workspace, knownHistory = [] }) { > {isMobile && <SidebarMobileHeader />} <div className="flex flex-col h-full w-full md:mt-0 mt-[40px]"> - <ChatHistory history={chatHistory} workspace={workspace} /> + <ChatHistory + history={chatHistory} + workspace={workspace} + sendCommand={sendCommand} + /> <PromptInput workspace={workspace} message={message} diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index d015918d4..811f662dd 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -168,6 +168,42 @@ const Workspace = { const data = await response.json(); return { response, data }; }, + + getSuggestedMessages: async function (slug) { + return await fetch(`${API_BASE}/workspace/${slug}/suggested-messages`, { + method: "GET", + cache: "no-cache", + headers: baseHeaders(), + }) + .then((res) => { + if (!res.ok) throw new Error("Could not fetch suggested messages."); + return res.json(); + }) + .then((res) => res.suggestedMessages) + .catch((e) => { + console.error(e); + return null; + }); + }, + setSuggestedMessages: async function (slug, messages) { + return fetch(`${API_BASE}/workspace/${slug}/suggested-messages`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ messages }), + }) + .then((res) => { + if (!res.ok) { + throw new Error( + res.statusText || "Error setting suggested messages." + ); + } + return { success: true, ...res.json() }; + }) + .catch((e) => { + console.error(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 7db652a92..9575b64ce 100644 --- a/frontend/src/pages/WorkspaceChat/index.jsx +++ b/frontend/src/pages/WorkspaceChat/index.jsx @@ -27,7 +27,11 @@ function ShowWorkspaceChat() { async function getWorkspace() { if (!slug) return; const _workspace = await Workspace.bySlug(slug); - setWorkspace(_workspace); + const suggestedMessages = await Workspace.getSuggestedMessages(slug); + setWorkspace({ + ..._workspace, + suggestedMessages, + }); setLoading(false); } getWorkspace(); diff --git a/frontend/src/pages/WorkspaceSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/index.jsx new file mode 100644 index 000000000..35743d137 --- /dev/null +++ b/frontend/src/pages/WorkspaceSettings/index.jsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { isMobile } from "react-device-detect"; +import showToast from "@/utils/toast"; +import { ArrowUUpLeft, Plus, X } from "@phosphor-icons/react"; +import Workspace from "@/models/workspace"; +import paths from "@/utils/paths"; + +export default function WorkspaceSettings() { + const [hasChanges, setHasChanges] = useState(false); + const [workspace, setWorkspace] = useState(null); + const [suggestedMessages, setSuggestedMessages] = useState([]); + const [editingIndex, setEditingIndex] = useState(-1); + const [newMessage, setNewMessage] = useState({ heading: "", message: "" }); + const { slug } = useParams(); + + useEffect(() => { + async function fetchWorkspace() { + if (!slug) return; + const workspace = await Workspace.bySlug(slug); + const suggestedMessages = await Workspace.getSuggestedMessages(slug); + setWorkspace(workspace); + setSuggestedMessages(suggestedMessages); + } + fetchWorkspace(); + }, [slug]); + + const handleSaveSuggestedMessages = async () => { + const validMessages = suggestedMessages.filter( + (msg) => + msg?.heading?.trim()?.length > 0 || msg?.message?.trim()?.length > 0 + ); + const { success, error } = await Workspace.setSuggestedMessages( + slug, + validMessages + ); + if (!success) { + showToast(`Failed to update welcome messages: ${error}`, "error"); + return; + } + showToast("Successfully updated welcome messages.", "success"); + setHasChanges(false); + }; + + const addMessage = () => { + setEditingIndex(-1); + if (suggestedMessages.length >= 4) { + showToast("Maximum of 4 messages allowed.", "warning"); + return; + } + const defaultMessage = { + heading: "Explain to me", + message: "the benefits of AnythingLLM", + }; + setNewMessage(defaultMessage); + setSuggestedMessages([...suggestedMessages, { ...defaultMessage }]); + setHasChanges(true); + }; + + const removeMessage = (index) => { + const messages = [...suggestedMessages]; + messages.splice(index, 1); + setSuggestedMessages(messages); + setHasChanges(true); + }; + + const startEditing = (index) => { + setEditingIndex(index); + setNewMessage({ ...suggestedMessages[index] }); + }; + + const handleRemoveMessage = (index) => { + removeMessage(index); + setEditingIndex(-1); + }; + + const onEditChange = (e) => { + const updatedNewMessage = { + ...newMessage, + [e.target.name]: e.target.value, + }; + setNewMessage(updatedNewMessage); + const updatedMessages = suggestedMessages.map((message, index) => { + if (index === editingIndex) { + return { ...message, [e.target.name]: e.target.value }; + } + return message; + }); + + setSuggestedMessages(updatedMessages); + setHasChanges(true); + }; + + return ( + <div className="w-screen h-screen overflow-hidden bg-sidebar flex"> + <a + href={paths.workspace.chat(slug)} + className="absolute top-2 left-2 md:top-16 md:left-10 transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border z-10" + > + <ArrowUUpLeft className="h-4 w-4" /> + </a> + <div + style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }} + className="transition-all duration-500 relative md:ml-[16px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent" + > + <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"> + Workspace Settings ({workspace?.name}) + </p> + </div> + <p className="text-sm font-base text-white text-opacity-60"> + Customize your workspace. + </p> + </div> + <div className="my-6"> + <div className="flex flex-col gap-y-2"> + <h2 className="leading-tight font-medium text-white"> + Suggested Chat Messages + </h2> + <p className="text-sm font-base text-white/60"> + Customize the messages that will be suggested to your workspace + users. + </p> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-white/60 text-xs mt-6 w-full justify-center max-w-[600px]"> + {suggestedMessages.map((suggestion, index) => ( + <div key={index} className="relative w-full"> + <button + className="transition-all duration-300 absolute z-10 text-neutral-700 bg-white rounded-full hover:bg-zinc-600 hover:border-zinc-600 hover:text-white border-transparent border shadow-lg ml-2" + style={{ + top: -8, + left: 265, + }} + onClick={() => handleRemoveMessage(index)} + > + <X className="m-[1px]" size={20} /> + </button> + <button + key={index} + onClick={() => startEditing(index)} + className={`text-left p-2.5 border rounded-xl w-full border-white/20 bg-sidebar hover:bg-workspace-item-selected-gradient ${ + editingIndex === index ? "border-sky-400" : "" + }`} + > + <p className="font-semibold">{suggestion.heading}</p> + <p>{suggestion.message}</p> + </button> + </div> + ))} + </div> + {editingIndex >= 0 && ( + <div className="flex flex-col gap-y-4 mr-2 mt-8"> + <div className="w-1/2"> + <label className="text-white text-sm font-semibold block mb-2"> + Heading + </label> + <input + placeholder="Message heading" + className=" bg-sidebar text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white block w-full p-2.5" + value={newMessage.heading} + name="heading" + onChange={onEditChange} + /> + </div> + <div className="w-1/2"> + <label className="text-white text-sm font-semibold block mb-2"> + Message + </label> + <input + placeholder="Message" + className="bg-sidebar text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white block w-full p-2.5" + value={newMessage.message} + name="message" + onChange={onEditChange} + /> + </div> + </div> + )} + {suggestedMessages.length < 4 && ( + <button + type="button" + onClick={addMessage} + className="flex gap-x-2 items-center justify-center mt-6 text-white text-sm hover:text-sky-400 transition-all duration-300" + > + Add new message <Plus className="" size={24} weight="fill" /> + </button> + )} + + {hasChanges && ( + <div className="flex justify-center py-6"> + <button + type="button" + className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800" + onClick={handleSaveSuggestedMessages} + > + Save Messages + </button> + </div> + )} + </div> + </div> + </div> + </div> + ); +} diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js index a9669300a..8fbaacecd 100644 --- a/frontend/src/utils/paths.js +++ b/frontend/src/utils/paths.js @@ -55,6 +55,9 @@ export default { chat: (slug) => { return `/workspace/${slug}`; }, + additionalSettings: (slug) => { + return `/workspace/${slug}/settings`; + }, }, apiDocs: () => { return `${API_BASE}/docs`; diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 25e391036..b04d23376 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -17,6 +17,9 @@ const { flexUserRoleValid, ROLES, } = require("../utils/middleware/multiUserProtected"); +const { + WorkspaceSuggestedMessages, +} = require("../models/workspacesSuggestedMessages"); const { handleUploads } = setupMulter(); function workspaceEndpoints(app) { @@ -283,6 +286,53 @@ function workspaceEndpoints(app) { } } ); + + app.get( + "/workspace/:slug/suggested-messages", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async function (request, response) { + try { + const { slug } = request.params; + const suggestedMessages = + await WorkspaceSuggestedMessages.getMessages(slug); + response.status(200).json({ success: true, suggestedMessages }); + } catch (error) { + console.error("Error fetching suggested messages:", error); + response + .status(500) + .json({ success: false, message: "Internal server error" }); + } + } + ); + + app.post( + "/workspace/:slug/suggested-messages", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { messages = [] } = reqBody(request); + const { slug } = request.params; + if (!Array.isArray(messages)) { + return response.status(400).json({ + success: false, + message: "Invalid message format. Expected an array of messages.", + }); + } + + await WorkspaceSuggestedMessages.saveAll(messages, slug); + return response.status(200).json({ + success: true, + message: "Suggested messages saved successfully.", + }); + } catch (error) { + console.error("Error processing the suggested messages:", error); + response.status(500).json({ + success: true, + message: "Error saving the suggested messages.", + }); + } + } + ); } module.exports = { workspaceEndpoints }; diff --git a/server/models/workspacesSuggestedMessages.js b/server/models/workspacesSuggestedMessages.js new file mode 100644 index 000000000..ef35a5bb5 --- /dev/null +++ b/server/models/workspacesSuggestedMessages.js @@ -0,0 +1,83 @@ +const prisma = require("../utils/prisma"); + +const WorkspaceSuggestedMessages = { + get: async function (clause = {}) { + try { + const message = await prisma.workspace_suggested_messages.findFirst({ + where: clause, + }); + return message || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function (clause = {}, limit) { + try { + const messages = await prisma.workspace_suggested_messages.findMany({ + where: clause, + take: limit || undefined, + }); + return messages; + } catch (error) { + console.error(error.message); + return []; + } + }, + + saveAll: async function (messages, workspaceSlug) { + try { + const workspace = await prisma.workspaces.findUnique({ + where: { slug: workspaceSlug }, + }); + + if (!workspace) throw new Error("Workspace not found"); + + // Delete all existing messages for the workspace + await prisma.workspace_suggested_messages.deleteMany({ + where: { workspaceId: workspace.id }, + }); + + // Create new messages + // We create each message individually because prisma + // with sqlite does not support createMany() + for (const message of messages) { + await prisma.workspace_suggested_messages.create({ + data: { + workspaceId: workspace.id, + heading: message.heading, + message: message.message, + }, + }); + } + } catch (error) { + console.error("Failed to save all messages", error.message); + } + }, + + getMessages: async function (workspaceSlug) { + try { + const workspace = await prisma.workspaces.findUnique({ + where: { slug: workspaceSlug }, + }); + + if (!workspace) throw new Error("Workspace not found"); + + const messages = await prisma.workspace_suggested_messages.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { createdAt: "asc" }, + }); + + return messages.map((msg) => ({ + heading: msg.heading, + message: msg.message, + })); + } catch (error) { + console.error("Failed to get all messages", error.message); + return []; + } + }, +}; + +module.exports.WorkspaceSuggestedMessages = WorkspaceSuggestedMessages; diff --git a/server/prisma/migrations/20240206181106_init/migration.sql b/server/prisma/migrations/20240206181106_init/migration.sql new file mode 100644 index 000000000..9655c7b7a --- /dev/null +++ b/server/prisma/migrations/20240206181106_init/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "workspace_suggested_messages" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "workspaceId" INTEGER NOT NULL, + "heading" TEXT NOT NULL, + "message" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_suggested_messages_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "workspace_suggested_messages_workspaceId_idx" ON "workspace_suggested_messages"("workspaceId"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 314a8359d..ede8a1fde 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -85,21 +85,34 @@ model welcome_messages { } model workspaces { - id Int @id @default(autoincrement()) - name String - slug String @unique - vectorTag String? - createdAt DateTime @default(now()) - openAiTemp Float? - openAiHistory Int @default(20) - lastUpdatedAt DateTime @default(now()) - openAiPrompt String? - similarityThreshold Float? @default(0.25) - chatModel String? - topN Int? @default(4) - workspace_users workspace_users[] - documents workspace_documents[] - embed_configs embed_configs[] + id Int @id @default(autoincrement()) + name String + slug String @unique + vectorTag String? + createdAt DateTime @default(now()) + openAiTemp Float? + openAiHistory Int @default(20) + lastUpdatedAt DateTime @default(now()) + openAiPrompt String? + similarityThreshold Float? @default(0.25) + chatModel String? + topN Int? @default(4) + workspace_users workspace_users[] + documents workspace_documents[] + workspace_suggested_messages workspace_suggested_messages[] + embed_configs embed_configs[] +} + +model workspace_suggested_messages { + id Int @id @default(autoincrement()) + workspaceId Int + heading String + message String + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@index([workspaceId]) } model workspace_chats { -- GitLab