From d36c3ff8b2fcb16edcd0992a75a289ba44f1cd77 Mon Sep 17 00:00:00 2001
From: Sean Hatfield <seanhatfield5@gmail.com>
Date: Fri, 10 May 2024 12:35:33 -0700
Subject: [PATCH] [FEAT] Slash templates (#1314)

* WIP slash presets

* WIP slash command customization CRUD + validations complete

* backend slash command support

* fix permission setting on new slash commands
rework form submit and pattern on frontend

* Add field updates for hooks,
required=true to field
add user<>command constraint to keep them unique
enforce uniquness via teritary uid field on table for multi and non-multi user

* reset migration

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
---
 .../SlashPresets/AddPresetModal.jsx           | 111 +++++++++++++
 .../SlashPresets/EditPresetModal.jsx          | 148 ++++++++++++++++++
 .../SlashCommands/SlashPresets/index.jsx      | 127 +++++++++++++++
 .../PromptInput/SlashCommands/index.jsx       |   4 +-
 frontend/src/models/system.js                 |  68 ++++++++
 server/endpoints/system.js                    | 106 +++++++++++++
 server/models/slashCommandsPresets.js         | 105 +++++++++++++
 .../20240510032311_init/migration.sql         |  15 ++
 server/prisma/schema.prisma                   |  15 ++
 server/utils/chats/index.js                   |  26 ++-
 server/utils/chats/stream.js                  |   8 +-
 11 files changed, 722 insertions(+), 11 deletions(-)
 create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/AddPresetModal.jsx
 create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/EditPresetModal.jsx
 create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/index.jsx
 create mode 100644 server/models/slashCommandsPresets.js
 create mode 100644 server/prisma/migrations/20240510032311_init/migration.sql

diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/AddPresetModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/AddPresetModal.jsx
new file mode 100644
index 000000000..e5154580b
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/AddPresetModal.jsx
@@ -0,0 +1,111 @@
+import { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import ModalWrapper from "@/components/ModalWrapper";
+import { CMD_REGEX } from ".";
+
+export default function AddPresetModal({ isOpen, onClose, onSave }) {
+  const [command, setCommand] = useState("");
+
+  const handleSubmit = async (e) => {
+    e.preventDefault();
+    const form = new FormData(e.target);
+    const sanitizedCommand = command.replace(CMD_REGEX, "");
+    const saved = await onSave({
+      command: `/${sanitizedCommand}`,
+      prompt: form.get("prompt"),
+      description: form.get("description"),
+    });
+    if (saved) setCommand("");
+  };
+
+  const handleCommandChange = (e) => {
+    const value = e.target.value.replace(CMD_REGEX, "");
+    setCommand(value);
+  };
+
+  return (
+    <ModalWrapper isOpen={isOpen}>
+      <form
+        onSubmit={handleSubmit}
+        className="relative w-full max-w-2xl max-h-full"
+      >
+        <div className="relative bg-main-gradient rounded-lg shadow">
+          <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
+            <h3 className="text-xl font-semibold text-white">Add New Preset</h3>
+            <button
+              onClick={onClose}
+              type="button"
+              className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
+            >
+              <X className="text-gray-300 text-lg" />
+            </button>
+          </div>
+          <div className="p-6 space-y-6 flex h-full w-full">
+            <div className="w-full flex flex-col gap-y-4">
+              <div>
+                <label className="block mb-2 text-sm font-medium text-white">
+                  Command
+                </label>
+                <div className="flex items-center">
+                  <span className="text-white text-sm mr-2 font-bold">/</span>
+                  <input
+                    name="command"
+                    type="text"
+                    placeholder="your-command"
+                    value={command}
+                    onChange={handleCommandChange}
+                    maxLength={25}
+                    autoComplete="off"
+                    required={true}
+                    className="border-none bg-zinc-900 placeholder:text-white/20 border-gray-500 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+                  />
+                </div>
+              </div>
+              <div>
+                <label className="block mb-2 text-sm font-medium text-white">
+                  Prompt
+                </label>
+                <textarea
+                  name="prompt"
+                  autoComplete="off"
+                  placeholder="This is the content that will be injected in front of your prompt."
+                  required={true}
+                  className="border-none bg-zinc-900 placeholder:text-white/20 border-gray-500 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+                ></textarea>
+              </div>
+              <div>
+                <label className="border-none block mb-2 text-sm font-medium text-white">
+                  Description
+                </label>
+                <input
+                  type="text"
+                  name="description"
+                  placeholder="Responds with a poem about LLMs."
+                  maxLength={80}
+                  autoComplete="off"
+                  required={true}
+                  className="border-none bg-zinc-900 placeholder:text-white/20 border-gray-500 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+                />
+              </div>
+            </div>
+          </div>
+          <div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
+            <button
+              onClick={onClose}
+              type="button"
+              className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300"
+            >
+              Cancel
+            </button>
+            <button
+              type="submit"
+              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"
+            >
+              Save
+            </button>
+          </div>
+        </div>
+      </form>
+    </ModalWrapper>
+  );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/EditPresetModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/EditPresetModal.jsx
new file mode 100644
index 000000000..fdffbe609
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/EditPresetModal.jsx
@@ -0,0 +1,148 @@
+import { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import ModalWrapper from "@/components/ModalWrapper";
+import { CMD_REGEX } from ".";
+
+export default function EditPresetModal({
+  isOpen,
+  onClose,
+  onSave,
+  onDelete,
+  preset,
+}) {
+  const [command, setCommand] = useState(preset?.command?.slice(1) || "");
+  const [deleting, setDeleting] = useState(false);
+
+  const handleSubmit = (e) => {
+    e.preventDefault();
+    const form = new FormData(e.target);
+    const sanitizedCommand = command.replace(CMD_REGEX, "");
+    onSave({
+      id: preset.id,
+      command: `/${sanitizedCommand}`,
+      prompt: form.get("prompt"),
+      description: form.get("description"),
+    });
+  };
+
+  const handleCommandChange = (e) => {
+    const value = e.target.value.replace(CMD_REGEX, "");
+    setCommand(value);
+  };
+
+  const handleDelete = async () => {
+    const confirmDelete = window.confirm(
+      "Are you sure you want to delete this preset?"
+    );
+    if (!confirmDelete) return;
+
+    setDeleting(true);
+    await onDelete(preset.id);
+    setDeleting(false);
+    onClose();
+  };
+
+  return (
+    <ModalWrapper isOpen={isOpen}>
+      <form
+        onSubmit={handleSubmit}
+        className="relative w-full max-w-2xl max-h-full"
+      >
+        <div className="relative bg-main-gradient rounded-lg shadow">
+          <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
+            <h3 className="text-xl font-semibold text-white">Edit Preset</h3>
+            <button
+              onClick={onClose}
+              type="button"
+              className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
+            >
+              <X className="text-gray-300 text-lg" />
+            </button>
+          </div>
+          <div className="p-6 space-y-6 flex h-full w-full">
+            <div className="w-full flex flex-col gap-y-4">
+              <div>
+                <label
+                  htmlFor="command"
+                  className="block mb-2 text-sm font-medium text-white"
+                >
+                  Command
+                </label>
+                <div className="flex items-center">
+                  <span className="text-white text-sm mr-2 font-bold">/</span>
+                  <input
+                    type="text"
+                    name="command"
+                    placeholder="your-command"
+                    value={command}
+                    onChange={handleCommandChange}
+                    required={true}
+                    className="border-none bg-zinc-900 placeholder:text-white/20 border-gray-500 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+                  />
+                </div>
+              </div>
+              <div>
+                <label
+                  htmlFor="prompt"
+                  className="block mb-2 text-sm font-medium text-white"
+                >
+                  Prompt
+                </label>
+                <textarea
+                  name="prompt"
+                  placeholder="This is a test prompt. Please respond with a poem about LLMs."
+                  defaultValue={preset.prompt}
+                  required={true}
+                  className="border-none bg-zinc-900 placeholder:text-white/20 border-gray-500 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+                ></textarea>
+              </div>
+              <div>
+                <label
+                  htmlFor="description"
+                  className="block mb-2 text-sm font-medium text-white"
+                >
+                  Description
+                </label>
+                <input
+                  type="text"
+                  name="description"
+                  defaultValue={preset.description}
+                  placeholder="Responds with a poem about LLMs."
+                  required={true}
+                  className="border-none bg-zinc-900 placeholder:text-white/20 border-gray-500 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+                />
+              </div>
+            </div>
+          </div>
+          <div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
+            <div className="flex flex-col space-y-2">
+              <button
+                disabled={deleting}
+                onClick={handleDelete}
+                type="button"
+                className="px-4 py-2 rounded-lg text-red-500 hover:bg-red-500/25 transition-all duration-300 disabled:opacity-50"
+              >
+                {deleting ? "Deleting..." : "Delete Preset"}
+              </button>
+            </div>
+            <div className="flex space-x-2">
+              <button
+                onClick={onClose}
+                type="button"
+                className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300"
+              >
+                Cancel
+              </button>
+              <button
+                type="submit"
+                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"
+              >
+                Save
+              </button>
+            </div>
+          </div>
+        </div>
+      </form>
+    </ModalWrapper>
+  );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/index.jsx
new file mode 100644
index 000000000..ca39b68a8
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/index.jsx
@@ -0,0 +1,127 @@
+import { useEffect, useState } from "react";
+import { useIsAgentSessionActive } from "@/utils/chat/agent";
+import AddPresetModal from "./AddPresetModal";
+import EditPresetModal from "./EditPresetModal";
+import { useModal } from "@/hooks/useModal";
+import System from "@/models/system";
+import { DotsThree, Plus } from "@phosphor-icons/react";
+import showToast from "@/utils/toast";
+
+export const CMD_REGEX = new RegExp(/[^a-zA-Z0-9_-]/g);
+export default function SlashPresets({ setShowing, sendCommand }) {
+  const isActiveAgentSession = useIsAgentSessionActive();
+  const {
+    isOpen: isAddModalOpen,
+    openModal: openAddModal,
+    closeModal: closeAddModal,
+  } = useModal();
+  const {
+    isOpen: isEditModalOpen,
+    openModal: openEditModal,
+    closeModal: closeEditModal,
+  } = useModal();
+  const [presets, setPresets] = useState([]);
+  const [selectedPreset, setSelectedPreset] = useState(null);
+
+  useEffect(() => {
+    fetchPresets();
+  }, []);
+  if (isActiveAgentSession) return null;
+
+  const fetchPresets = async () => {
+    const presets = await System.getSlashCommandPresets();
+    setPresets(presets);
+  };
+
+  const handleSavePreset = async (preset) => {
+    const { error } = await System.createSlashCommandPreset(preset);
+    if (!!error) {
+      showToast(error, "error");
+      return false;
+    }
+
+    fetchPresets();
+    closeAddModal();
+    return true;
+  };
+
+  const handleEditPreset = (preset) => {
+    setSelectedPreset(preset);
+    openEditModal();
+  };
+
+  const handleUpdatePreset = async (updatedPreset) => {
+    const { error } = await System.updateSlashCommandPreset(
+      updatedPreset.id,
+      updatedPreset
+    );
+
+    if (!!error) {
+      showToast(error, "error");
+      return;
+    }
+
+    fetchPresets();
+    closeEditModal();
+  };
+
+  const handleDeletePreset = async (presetId) => {
+    await System.deleteSlashCommandPreset(presetId);
+    fetchPresets();
+    closeEditModal();
+  };
+
+  return (
+    <>
+      {presets.map((preset) => (
+        <button
+          key={preset.id}
+          onClick={() => {
+            setShowing(false);
+            sendCommand(`${preset.command} `, false);
+          }}
+          className="w-full hover:cursor-pointer hover:bg-zinc-700 px-2 py-2 rounded-xl flex flex-row justify-start"
+        >
+          <div className="w-full flex-col text-left flex pointer-events-none">
+            <div className="text-white text-sm font-bold">{preset.command}</div>
+            <div className="text-white text-opacity-60 text-sm">
+              {preset.description}
+            </div>
+          </div>
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              handleEditPreset(preset);
+            }}
+            className="text-white text-sm p-1 hover:cursor-pointer hover:bg-zinc-900 rounded-full mt-1"
+          >
+            <DotsThree size={24} weight="bold" />
+          </button>
+        </button>
+      ))}
+      <button
+        onClick={openAddModal}
+        className="w-full hover:cursor-pointer hover:bg-zinc-700 px-2 py-1 rounded-xl flex flex-col justify-start"
+      >
+        <div className="w-full flex-row flex pointer-events-none items-center gap-2">
+          <Plus size={24} weight="fill" fill="white" />
+          <div className="text-white text-sm font-medium">Add New Preset </div>
+        </div>
+      </button>
+      <AddPresetModal
+        isOpen={isAddModalOpen}
+        onClose={closeAddModal}
+        onSave={handleSavePreset}
+      />
+      {selectedPreset && (
+        <EditPresetModal
+          isOpen={isEditModalOpen}
+          onClose={closeEditModal}
+          onSave={handleUpdatePreset}
+          onDelete={handleDeletePreset}
+          preset={selectedPreset}
+        />
+      )}
+    </>
+  );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/index.jsx
index 5a606af6d..9b626372c 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/index.jsx
@@ -3,6 +3,7 @@ import SlashCommandIcon from "./icons/slash-commands-icon.svg";
 import { Tooltip } from "react-tooltip";
 import ResetCommand from "./reset";
 import EndAgentSession from "./endAgentSession";
+import SlashPresets from "./SlashPresets";
 
 export default function SlashCommandsButton({ showing, setShowSlashCommand }) {
   return (
@@ -52,10 +53,11 @@ export function SlashCommands({ showing, setShowing, sendCommand }) {
       <div className="w-full flex justify-center absolute bottom-[130px] md:bottom-[150px] left-0 z-10 px-4">
         <div
           ref={cmdRef}
-          className="w-[600px] p-2 bg-zinc-800 rounded-2xl shadow flex-col justify-center items-start gap-2.5 inline-flex"
+          className="w-[600px] overflow-auto p-2 bg-zinc-800 rounded-2xl shadow flex-col justify-center items-start gap-2.5 inline-flex"
         >
           <ResetCommand sendCommand={sendCommand} setShowing={setShowing} />
           <EndAgentSession sendCommand={sendCommand} setShowing={setShowing} />
+          <SlashPresets sendCommand={sendCommand} setShowing={setShowing} />
         </div>
       </div>
     </div>
diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js
index af532a047..e64b01199 100644
--- a/frontend/src/models/system.js
+++ b/frontend/src/models/system.js
@@ -567,6 +567,74 @@ const System = {
       });
   },
   dataConnectors: DataConnector,
+
+  getSlashCommandPresets: async function () {
+    return await fetch(`${API_BASE}/system/slash-command-presets`, {
+      method: "GET",
+      headers: baseHeaders(),
+    })
+      .then((res) => {
+        if (!res.ok) throw new Error("Could not fetch slash command presets.");
+        return res.json();
+      })
+      .then((res) => res.presets)
+      .catch((e) => {
+        console.error(e);
+        return [];
+      });
+  },
+
+  createSlashCommandPreset: async function (presetData) {
+    return await fetch(`${API_BASE}/system/slash-command-presets`, {
+      method: "POST",
+      headers: baseHeaders(),
+      body: JSON.stringify(presetData),
+    })
+      .then((res) => {
+        if (!res.ok) throw new Error("Could not create slash command preset.");
+        return res.json();
+      })
+      .then((res) => {
+        return { preset: res.preset, error: null };
+      })
+      .catch((e) => {
+        console.error(e);
+        return { preset: null, error: e.message };
+      });
+  },
+
+  updateSlashCommandPreset: async function (presetId, presetData) {
+    return await fetch(`${API_BASE}/system/slash-command-presets/${presetId}`, {
+      method: "POST",
+      headers: baseHeaders(),
+      body: JSON.stringify(presetData),
+    })
+      .then((res) => {
+        if (!res.ok) throw new Error("Could not update slash command preset.");
+        return res.json();
+      })
+      .then((res) => {
+        return { preset: res.preset, error: null };
+      })
+      .catch((e) => {
+        return { preset: null, error: "Failed to update this command." };
+      });
+  },
+
+  deleteSlashCommandPreset: async function (presetId) {
+    return await fetch(`${API_BASE}/system/slash-command-presets/${presetId}`, {
+      method: "DELETE",
+      headers: baseHeaders(),
+    })
+      .then((res) => {
+        if (!res.ok) throw new Error("Could not delete slash command preset.");
+        return true;
+      })
+      .catch((e) => {
+        console.error(e);
+        return false;
+      });
+  },
 };
 
 export default System;
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index 60d51e35f..4538ee060 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -50,6 +50,7 @@ const {
   resetPassword,
   generateRecoveryCodes,
 } = require("../utils/PasswordRecovery");
+const { SlashCommandPresets } = require("../models/slashCommandsPresets");
 
 function systemEndpoints(app) {
   if (!app) return;
@@ -1044,6 +1045,111 @@ function systemEndpoints(app) {
       response.sendStatus(500).end();
     }
   });
+
+  app.get(
+    "/system/slash-command-presets",
+    [validatedRequest, flexUserRoleValid([ROLES.all])],
+    async (request, response) => {
+      try {
+        const user = await userFromSession(request, response);
+        const userPresets = await SlashCommandPresets.getUserPresets(user?.id);
+        response.status(200).json({ presets: userPresets });
+      } catch (error) {
+        console.error("Error fetching slash command presets:", error);
+        response.status(500).json({ message: "Internal server error" });
+      }
+    }
+  );
+
+  app.post(
+    "/system/slash-command-presets",
+    [validatedRequest, flexUserRoleValid([ROLES.all])],
+    async (request, response) => {
+      try {
+        const user = await userFromSession(request, response);
+        const { command, prompt, description } = reqBody(request);
+        const presetData = {
+          command: SlashCommandPresets.formatCommand(String(command)),
+          prompt: String(prompt),
+          description: String(description),
+        };
+
+        const preset = await SlashCommandPresets.create(user?.id, presetData);
+        if (!preset) {
+          return response
+            .status(500)
+            .json({ message: "Failed to create preset" });
+        }
+        response.status(201).json({ preset });
+      } catch (error) {
+        console.error("Error creating slash command preset:", error);
+        response.status(500).json({ message: "Internal server error" });
+      }
+    }
+  );
+
+  app.post(
+    "/system/slash-command-presets/:slashCommandId",
+    [validatedRequest, flexUserRoleValid([ROLES.all])],
+    async (request, response) => {
+      try {
+        const user = await userFromSession(request, response);
+        const { slashCommandId } = request.params;
+        const { command, prompt, description } = reqBody(request);
+
+        // Valid user running owns the preset if user session is valid.
+        const ownsPreset = await SlashCommandPresets.get({
+          userId: user?.id ?? null,
+          id: Number(slashCommandId),
+        });
+        if (!ownsPreset)
+          return response.status(404).json({ message: "Preset not found" });
+
+        const updates = {
+          command: SlashCommandPresets.formatCommand(String(command)),
+          prompt: String(prompt),
+          description: String(description),
+        };
+
+        const preset = await SlashCommandPresets.update(
+          Number(slashCommandId),
+          updates
+        );
+        if (!preset) return response.sendStatus(422);
+        response.status(200).json({ preset: { ...ownsPreset, ...updates } });
+      } catch (error) {
+        console.error("Error updating slash command preset:", error);
+        response.status(500).json({ message: "Internal server error" });
+      }
+    }
+  );
+
+  app.delete(
+    "/system/slash-command-presets/:slashCommandId",
+    [validatedRequest, flexUserRoleValid([ROLES.all])],
+    async (request, response) => {
+      try {
+        const { slashCommandId } = request.params;
+        const user = await userFromSession(request, response);
+
+        // Valid user running owns the preset if user session is valid.
+        const ownsPreset = await SlashCommandPresets.get({
+          userId: user?.id ?? null,
+          id: Number(slashCommandId),
+        });
+        if (!ownsPreset)
+          return response
+            .status(403)
+            .json({ message: "Failed to delete preset" });
+
+        await SlashCommandPresets.delete(Number(slashCommandId));
+        response.sendStatus(204);
+      } catch (error) {
+        console.error("Error deleting slash command preset:", error);
+        response.status(500).json({ message: "Internal server error" });
+      }
+    }
+  );
 }
 
 module.exports = { systemEndpoints };
diff --git a/server/models/slashCommandsPresets.js b/server/models/slashCommandsPresets.js
new file mode 100644
index 000000000..4828c77d5
--- /dev/null
+++ b/server/models/slashCommandsPresets.js
@@ -0,0 +1,105 @@
+const { v4 } = require("uuid");
+const prisma = require("../utils/prisma");
+const CMD_REGEX = new RegExp(/[^a-zA-Z0-9_-]/g);
+
+const SlashCommandPresets = {
+  formatCommand: function (command = "") {
+    if (!command || command.length < 2) return `/${v4().split("-")[0]}`;
+
+    let adjustedCmd = command.toLowerCase(); // force lowercase
+    if (!adjustedCmd.startsWith("/")) adjustedCmd = `/${adjustedCmd}`; // Fix if no preceding / is found.
+    return `/${adjustedCmd.slice(1).toLowerCase().replace(CMD_REGEX, "-")}`; // replace any invalid chars with '-'
+  },
+
+  get: async function (clause = {}) {
+    try {
+      const preset = await prisma.slash_command_presets.findFirst({
+        where: clause,
+      });
+      return preset || null;
+    } catch (error) {
+      console.error(error.message);
+      return null;
+    }
+  },
+
+  where: async function (clause = {}, limit) {
+    try {
+      const presets = await prisma.slash_command_presets.findMany({
+        where: clause,
+        take: limit || undefined,
+      });
+      return presets;
+    } catch (error) {
+      console.error(error.message);
+      return [];
+    }
+  },
+
+  // Command + userId must be unique combination.
+  create: async function (userId = null, presetData = {}) {
+    try {
+      const preset = await prisma.slash_command_presets.create({
+        data: {
+          ...presetData,
+          // This field (uid) is either the user_id or 0 (for non-multi-user mode).
+          // the UID field enforces the @@unique(userId, command) constraint since
+          // the real relational field (userId) cannot be non-null so this 'dummy' field gives us something
+          // to constrain against within the context of prisma and sqlite that works.
+          uid: userId ? Number(userId) : 0,
+          userId: userId ? Number(userId) : null,
+        },
+      });
+      return preset;
+    } catch (error) {
+      console.error("Failed to create preset", error.message);
+      return null;
+    }
+  },
+
+  getUserPresets: async function (userId = null) {
+    try {
+      return (
+        await prisma.slash_command_presets.findMany({
+          where: { userId: !!userId ? Number(userId) : null },
+          orderBy: { createdAt: "asc" },
+        })
+      )?.map((preset) => ({
+        id: preset.id,
+        command: preset.command,
+        prompt: preset.prompt,
+        description: preset.description,
+      }));
+    } catch (error) {
+      console.error("Failed to get user presets", error.message);
+      return [];
+    }
+  },
+
+  update: async function (presetId = null, presetData = {}) {
+    try {
+      const preset = await prisma.slash_command_presets.update({
+        where: { id: Number(presetId) },
+        data: presetData,
+      });
+      return preset;
+    } catch (error) {
+      console.error("Failed to update preset", error.message);
+      return null;
+    }
+  },
+
+  delete: async function (presetId = null) {
+    try {
+      await prisma.slash_command_presets.delete({
+        where: { id: Number(presetId) },
+      });
+      return true;
+    } catch (error) {
+      console.error("Failed to delete preset", error.message);
+      return false;
+    }
+  },
+};
+
+module.exports.SlashCommandPresets = SlashCommandPresets;
diff --git a/server/prisma/migrations/20240510032311_init/migration.sql b/server/prisma/migrations/20240510032311_init/migration.sql
new file mode 100644
index 000000000..3b82efb88
--- /dev/null
+++ b/server/prisma/migrations/20240510032311_init/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "slash_command_presets" (
+    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+    "command" TEXT NOT NULL,
+    "prompt" TEXT NOT NULL,
+    "description" TEXT NOT NULL,
+    "uid" INTEGER NOT NULL DEFAULT 0,
+    "userId" INTEGER,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT "slash_command_presets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "slash_command_presets_uid_command_key" ON "slash_command_presets"("uid", "command");
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index b830de9b7..0ded65be6 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -73,6 +73,7 @@ model users {
   recovery_codes              recovery_codes[]
   password_reset_tokens       password_reset_tokens[]
   workspace_agent_invocations workspace_agent_invocations[]
+  slash_command_presets       slash_command_presets[]
 }
 
 model recovery_codes {
@@ -260,3 +261,17 @@ model event_logs {
 
   @@index([event])
 }
+
+model slash_command_presets {
+  id            Int      @id @default(autoincrement())
+  command       String
+  prompt        String
+  description   String
+  uid           Int      @default(0) // 0 is null user
+  userId        Int?
+  createdAt     DateTime @default(now())
+  lastUpdatedAt DateTime @default(now())
+  user          users?   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  @@unique([uid, command])
+}
diff --git a/server/utils/chats/index.js b/server/utils/chats/index.js
index 76f98e0df..55e8fbe5f 100644
--- a/server/utils/chats/index.js
+++ b/server/utils/chats/index.js
@@ -4,14 +4,28 @@ const { resetMemory } = require("./commands/reset");
 const { getVectorDbClass, getLLMProvider } = require("../helpers");
 const { convertToPromptHistory } = require("../helpers/chat/responses");
 const { DocumentManager } = require("../DocumentManager");
+const { SlashCommandPresets } = require("../../models/slashCommandsPresets");
 
 const VALID_COMMANDS = {
   "/reset": resetMemory,
 };
 
-function grepCommand(message) {
+async function grepCommand(message, user = null) {
+  const userPresets = await SlashCommandPresets.getUserPresets(user?.id);
   const availableCommands = Object.keys(VALID_COMMANDS);
 
+  // Check if the message starts with any preset command
+  const foundPreset = userPresets.find((p) => message.startsWith(p.command));
+  if (!!foundPreset) {
+    // Replace the preset command with the corresponding prompt
+    const updatedMessage = message.replace(
+      foundPreset.command,
+      foundPreset.prompt
+    );
+    return updatedMessage;
+  }
+
+  // Check if the message starts with any built-in command
   for (let i = 0; i < availableCommands.length; i++) {
     const cmd = availableCommands[i];
     const re = new RegExp(`^(${cmd})`, "i");
@@ -20,7 +34,7 @@ function grepCommand(message) {
     }
   }
 
-  return null;
+  return message;
 }
 
 async function chatWithWorkspace(
@@ -31,10 +45,10 @@ async function chatWithWorkspace(
   thread = null
 ) {
   const uuid = uuidv4();
-  const command = grepCommand(message);
+  const updatedMessage = await grepCommand(message, user);
 
-  if (!!command && Object.keys(VALID_COMMANDS).includes(command)) {
-    return await VALID_COMMANDS[command](workspace, message, uuid, user);
+  if (Object.keys(VALID_COMMANDS).includes(updatedMessage)) {
+    return await VALID_COMMANDS[updatedMessage](workspace, message, uuid, user);
   }
 
   const LLMConnector = getLLMProvider({
@@ -164,7 +178,7 @@ async function chatWithWorkspace(
   const messages = await LLMConnector.compressMessages(
     {
       systemPrompt: chatPrompt(workspace),
-      userPrompt: message,
+      userPrompt: updatedMessage,
       contextTexts,
       chatHistory,
     },
diff --git a/server/utils/chats/stream.js b/server/utils/chats/stream.js
index ba4dea163..ec8fdbfac 100644
--- a/server/utils/chats/stream.js
+++ b/server/utils/chats/stream.js
@@ -23,10 +23,10 @@ async function streamChatWithWorkspace(
   thread = null
 ) {
   const uuid = uuidv4();
-  const command = grepCommand(message);
+  const updatedMessage = await grepCommand(message, user);
 
-  if (!!command && Object.keys(VALID_COMMANDS).includes(command)) {
-    const data = await VALID_COMMANDS[command](
+  if (Object.keys(VALID_COMMANDS).includes(updatedMessage)) {
+    const data = await VALID_COMMANDS[updatedMessage](
       workspace,
       message,
       uuid,
@@ -185,7 +185,7 @@ async function streamChatWithWorkspace(
   const messages = await LLMConnector.compressMessages(
     {
       systemPrompt: chatPrompt(workspace),
-      userPrompt: message,
+      userPrompt: updatedMessage,
       contextTexts,
       chatHistory,
     },