[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>
This commit is contained in:
Sean Hatfield 2024-02-06 11:24:33 -08:00 committed by GitHub
parent 2bc11d3f1a
commit 608f28d745
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 483 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,6 +55,9 @@ export default {
chat: (slug) => {
return `/workspace/${slug}`;
},
additionalSettings: (slug) => {
return `/workspace/${slug}/settings`;
},
},
apiDocs: () => {
return `${API_BASE}/docs`;

View File

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

View File

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

View File

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

View File

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