From 452582489e4f6fe519ac809e6d4717319d8abf97 Mon Sep 17 00:00:00 2001
From: Timothy Carambat <rambat1010@gmail.com>
Date: Mon, 18 Dec 2023 15:48:02 -0800
Subject: [PATCH] GitHub loader extension + extension support v1 (#469)

* feat: implement github repo loading
fix: purge of folders
fix: rendering of sub-files

* noshow delete on custom-documents

* Add API key support because of rate limits

* WIP for frontend of data connectors

* wip

* Add frontend form for GitHub repo data connector

* remove console.logs
block custom-documents from being deleted

* remove _meta unused arg

* Add support for ignore pathing in request
Ignore path input via tagging

* Update hint
---
 collector/extensions/index.js                 |  52 ++++
 collector/index.js                            |   3 +
 collector/package.json                        |   4 +-
 .../extensions/GithubRepo/RepoLoader/index.js | 149 +++++++++
 .../utils/extensions/GithubRepo/index.js      |  78 +++++
 collector/yarn.lock                           |  10 +
 frontend/package.json                         |   1 +
 frontend/src/App.jsx                          |  15 +
 .../components/DataConnectorOption/index.jsx  |  39 +++
 .../DataConnectorOption/media/github.png      | Bin 0 -> 22064 bytes
 .../DataConnectorOption/media/index.js        |   5 +
 .../Documents/Directory/FileRow/index.jsx     |   4 +-
 .../Documents/Directory/FolderRow/index.jsx   |  42 ++-
 .../Documents/Directory/index.jsx             |   1 +
 .../src/components/SettingsSidebar/index.jsx  |   6 +
 .../src/components/Sidebar/IndexCount.jsx     |  34 --
 frontend/src/components/Sidebar/LLMStatus.jsx |  49 ---
 frontend/src/components/Sidebar/index.jsx     |  19 --
 frontend/src/index.css                        |   4 +
 frontend/src/models/dataConnector.js          |  47 +++
 frontend/src/models/system.js                 |  18 +-
 .../Connectors/Github/index.jsx               | 294 ++++++++++++++++++
 .../DataConnectors/Connectors/index.jsx       |  19 ++
 .../GeneralSettings/DataConnectors/index.jsx  |  38 +++
 frontend/src/utils/paths.js                   |   8 +
 frontend/yarn.lock                            |   5 +
 server/endpoints/extensions/index.js          |  53 ++++
 server/endpoints/system.js                    |  21 +-
 server/index.js                               |   2 +
 server/utils/files/documentProcessor.js       |  23 ++
 server/utils/files/index.js                   |  16 +-
 server/utils/files/purgeDocument.js           |  44 ++-
 32 files changed, 975 insertions(+), 128 deletions(-)
 create mode 100644 collector/extensions/index.js
 create mode 100644 collector/utils/extensions/GithubRepo/RepoLoader/index.js
 create mode 100644 collector/utils/extensions/GithubRepo/index.js
 create mode 100644 frontend/src/components/DataConnectorOption/index.jsx
 create mode 100644 frontend/src/components/DataConnectorOption/media/github.png
 create mode 100644 frontend/src/components/DataConnectorOption/media/index.js
 delete mode 100644 frontend/src/components/Sidebar/IndexCount.jsx
 delete mode 100644 frontend/src/components/Sidebar/LLMStatus.jsx
 create mode 100644 frontend/src/models/dataConnector.js
 create mode 100644 frontend/src/pages/GeneralSettings/DataConnectors/Connectors/Github/index.jsx
 create mode 100644 frontend/src/pages/GeneralSettings/DataConnectors/Connectors/index.jsx
 create mode 100644 frontend/src/pages/GeneralSettings/DataConnectors/index.jsx
 create mode 100644 server/endpoints/extensions/index.js

diff --git a/collector/extensions/index.js b/collector/extensions/index.js
new file mode 100644
index 000000000..7b131b646
--- /dev/null
+++ b/collector/extensions/index.js
@@ -0,0 +1,52 @@
+const { reqBody } = require("../utils/http");
+
+function extensions(app) {
+  if (!app) return;
+
+  app.post("/ext/github-repo", async function (request, response) {
+    try {
+      const loadGithubRepo = require("../utils/extensions/GithubRepo");
+      const { success, reason, data } = await loadGithubRepo(reqBody(request));
+      response.status(200).json({
+        success,
+        reason,
+        data
+      });
+    } catch (e) {
+      console.error(e);
+      response.status(200).json({
+        success: false,
+        reason: e.message || "A processing error occurred.",
+        data: {},
+      });
+    }
+    return;
+  });
+
+  // gets all branches for a specific repo
+  app.post("/ext/github-repo/branches", async function (request, response) {
+    try {
+      const GithubRepoLoader = require("../utils/extensions/GithubRepo/RepoLoader");
+      const allBranches = await (new GithubRepoLoader(reqBody(request))).getRepoBranches()
+      response.status(200).json({
+        success: true,
+        reason: null,
+        data: {
+          branches: allBranches
+        }
+      });
+    } catch (e) {
+      console.error(e);
+      response.status(400).json({
+        success: false,
+        reason: e.message,
+        data: {
+          branches: []
+        }
+      });
+    }
+    return;
+  });
+}
+
+module.exports = extensions;
diff --git a/collector/index.js b/collector/index.js
index 4cbca6c21..5070ae72f 100644
--- a/collector/index.js
+++ b/collector/index.js
@@ -11,6 +11,7 @@ const { reqBody } = require("./utils/http");
 const { processSingleFile } = require("./processSingleFile");
 const { processLink } = require("./processLink");
 const { wipeCollectorStorage } = require("./utils/files");
+const extensions = require("./extensions");
 const app = express();
 
 app.use(cors({ origin: true }));
@@ -57,6 +58,8 @@ app.post("/process-link", async function (request, response) {
   return;
 });
 
+extensions(app);
+
 app.get("/accepts", function (_, response) {
   response.status(200).json(ACCEPTED_MIMES);
 });
diff --git a/collector/package.json b/collector/package.json
index 180a20888..fb9bed67a 100644
--- a/collector/package.json
+++ b/collector/package.json
@@ -24,6 +24,7 @@
     "express": "^4.18.2",
     "extract-zip": "^2.0.1",
     "fluent-ffmpeg": "^2.1.2",
+    "ignore": "^5.3.0",
     "js-tiktoken": "^1.0.8",
     "langchain": "0.0.201",
     "mammoth": "^1.6.0",
@@ -35,6 +36,7 @@
     "pdf-parse": "^1.1.1",
     "puppeteer": "^21.6.1",
     "slugify": "^1.6.6",
+    "url-pattern": "^1.0.3",
     "uuid": "^9.0.0",
     "wavefile": "^11.0.0"
   },
@@ -42,4 +44,4 @@
     "nodemon": "^2.0.22",
     "prettier": "^2.4.1"
   }
-}
\ No newline at end of file
+}
diff --git a/collector/utils/extensions/GithubRepo/RepoLoader/index.js b/collector/utils/extensions/GithubRepo/RepoLoader/index.js
new file mode 100644
index 000000000..7f1c1c057
--- /dev/null
+++ b/collector/utils/extensions/GithubRepo/RepoLoader/index.js
@@ -0,0 +1,149 @@
+class RepoLoader {
+  constructor(args = {}) {
+    this.ready = false;
+    this.repo = args?.repo;
+    this.branch = args?.branch;
+    this.accessToken = args?.accessToken || null;
+    this.ignorePaths = args?.ignorePaths || [];
+
+    this.author = null;
+    this.project = null;
+    this.branches = [];
+  }
+
+  #validGithubUrl() {
+    const UrlPattern = require("url-pattern");
+    const pattern = new UrlPattern("https\\://github.com/(:author)/(:project)");
+    const match = pattern.match(this.repo);
+    if (!match) return false;
+
+    this.author = match.author;
+    this.project = match.project;
+    return true;
+  }
+
+  // Ensure the branch provided actually exists
+  // and if it does not or has not been set auto-assign to primary branch.
+  async #validBranch() {
+    await this.getRepoBranches();
+    if (!!this.branch && this.branches.includes(this.branch)) return;
+
+    console.log(
+      "[Github Loader]: Branch not set! Auto-assigning to a default branch."
+    );
+    this.branch = this.branches.includes("main") ? "main" : "master";
+    console.log(`[Github Loader]: Branch auto-assigned to ${this.branch}.`);
+    return;
+  }
+
+  async #validateAccessToken() {
+    if (!this.accessToken) return;
+    const valid = await fetch("https://api.github.com/octocat", {
+      method: "GET",
+      headers: {
+        Authorization: `Bearer ${this.accessToken}`,
+        "X-GitHub-Api-Version": "2022-11-28",
+      },
+    })
+      .then((res) => {
+        if (!res.ok) throw new Error(res.statusText);
+        return res.ok;
+      })
+      .catch((e) => {
+        console.error(
+          "Invalid Github Access Token provided! Access token will not be used",
+          e.message
+        );
+        return false;
+      });
+
+    if (!valid) this.accessToken = null;
+    return;
+  }
+
+  async init() {
+    if (!this.#validGithubUrl()) return;
+    await this.#validBranch();
+    await this.#validateAccessToken();
+    this.ready = true;
+    return this;
+  }
+
+  async recursiveLoader() {
+    if (!this.ready) throw new Error("[Github Loader]: not in ready state!");
+    const {
+      GithubRepoLoader: LCGithubLoader,
+    } = require("langchain/document_loaders/web/github");
+
+    if (this.accessToken)
+      console.log(
+        `[Github Loader]: Access token set! Recursive loading enabled!`
+      );
+
+    const loader = new LCGithubLoader(this.repo, {
+      accessToken: this.accessToken,
+      branch: this.branch,
+      recursive: !!this.accessToken, // Recursive will hit rate limits.
+      maxConcurrency: 5,
+      unknown: "ignore",
+      ignorePaths: this.ignorePaths,
+    });
+
+    const docs = [];
+    for await (const doc of loader.loadAsStream()) docs.push(doc);
+    return docs;
+  }
+
+  // Sort branches to always show either main or master at the top of the result.
+  #branchPrefSort(branches = []) {
+    const preferredSort = ["main", "master"];
+    return branches.reduce((acc, branch) => {
+      if (preferredSort.includes(branch)) return [branch, ...acc];
+      return [...acc, branch];
+    }, []);
+  }
+
+  // Get all branches for a given repo.
+  async getRepoBranches() {
+    if (!this.#validGithubUrl() || !this.author || !this.project) return [];
+    await this.#validateAccessToken(); // Ensure API access token is valid for pre-flight
+
+    let page = 0;
+    let polling = true;
+    const branches = [];
+
+    while (polling) {
+      console.log(`Fetching page ${page} of branches for ${this.project}`);
+      await fetch(
+        `https://api.github.com/repos/${this.author}/${this.project}/branches?per_page=100&page=${page}`,
+        {
+          method: "GET",
+          headers: {
+            ...(this.accessToken
+              ? { Authorization: `Bearer ${this.accessToken}` }
+              : {}),
+            "X-GitHub-Api-Version": "2022-11-28",
+          },
+        }
+      )
+        .then((res) => {
+          if (res.ok) return res.json();
+          throw new Error(`Invalid request to Github API: ${res.statusText}`);
+        })
+        .then((branchObjects) => {
+          polling = branchObjects.length > 0;
+          branches.push(branchObjects.map((branch) => branch.name));
+          page++;
+        })
+        .catch((err) => {
+          polling = false;
+          console.log(`RepoLoader.branches`, err);
+        });
+    }
+
+    this.branches = [...new Set(branches.flat())];
+    return this.#branchPrefSort(this.branches);
+  }
+}
+
+module.exports = RepoLoader;
diff --git a/collector/utils/extensions/GithubRepo/index.js b/collector/utils/extensions/GithubRepo/index.js
new file mode 100644
index 000000000..d459e6357
--- /dev/null
+++ b/collector/utils/extensions/GithubRepo/index.js
@@ -0,0 +1,78 @@
+const RepoLoader = require("./RepoLoader");
+const fs = require("fs");
+const path = require("path");
+const { default: slugify } = require("slugify");
+const { v4 } = require("uuid");
+const { writeToServerDocuments } = require("../../files");
+const { tokenizeString } = require("../../tokenizer");
+
+async function loadGithubRepo(args) {
+  const repo = new RepoLoader(args);
+  await repo.init();
+
+  if (!repo.ready)
+    return {
+      success: false,
+      reason: "Could not prepare Github repo for loading! Check URL",
+    };
+
+  console.log(
+    `-- Working Github ${repo.author}/${repo.project}:${repo.branch} --`
+  );
+  const docs = await repo.recursiveLoader();
+  if (!docs.length) {
+    return {
+      success: false,
+      reason: "No files were found for those settings.",
+    };
+  }
+
+  console.log(`[Github Loader]: Found ${docs.length} source files. Saving...`);
+  const outFolder = slugify(
+    `${repo.author}-${repo.project}-${repo.branch}-${v4().slice(0, 4)}`
+  ).toLowerCase();
+  const outFolderPath = path.resolve(
+    __dirname,
+    `../../../../server/storage/documents/${outFolder}`
+  );
+  fs.mkdirSync(outFolderPath);
+
+  for (const doc of docs) {
+    if (!doc.pageContent) continue;
+    const data = {
+      id: v4(),
+      url: "github://" + doc.metadata.source,
+      title: doc.metadata.source,
+      docAuthor: repo.author,
+      description: "No description found.",
+      docSource: repo.repo,
+      chunkSource: doc.metadata.source,
+      published: new Date().toLocaleString(),
+      wordCount: doc.pageContent.split(" ").length,
+      pageContent: doc.pageContent,
+      token_count_estimate: tokenizeString(doc.pageContent).length,
+    };
+    console.log(
+      `[Github Loader]: Saving ${doc.metadata.source} to ${outFolder}`
+    );
+    writeToServerDocuments(
+      data,
+      `${slugify(doc.metadata.source)}-${data.id}`,
+      outFolderPath
+    );
+  }
+
+  return {
+    success: true,
+    reason: null,
+    data: {
+      author: repo.author,
+      repo: repo.project,
+      branch: repo.branch,
+      files: docs.length,
+      destination: outFolder,
+    },
+  };
+}
+
+module.exports = loadGithubRepo;
diff --git a/collector/yarn.lock b/collector/yarn.lock
index c6f442a80..28c610926 100644
--- a/collector/yarn.lock
+++ b/collector/yarn.lock
@@ -1530,6 +1530,11 @@ ignore-by-default@^1.0.1:
   resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
   integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==
 
+ignore@^5.3.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
+  integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
+
 immediate@~3.0.5:
   version "3.0.6"
   resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
@@ -3127,6 +3132,11 @@ unpipe@1.0.0, unpipe@~1.0.0:
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
 
+url-pattern@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/url-pattern/-/url-pattern-1.0.3.tgz#0409292471b24f23c50d65a47931793d2b5acfc1"
+  integrity sha512-uQcEj/2puA4aq1R3A2+VNVBgaWYR24FdWjl7VNW83rnWftlhyzOZ/tBjezRiC2UkIzuxC8Top3IekN3vUf1WxA==
+
 url-template@^2.0.8:
   version "2.0.8"
   resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
diff --git a/frontend/package.json b/frontend/package.json
index a1531b9cc..19fe1d0ab 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -27,6 +27,7 @@
     "react-loading-icons": "^1.1.0",
     "react-loading-skeleton": "^3.1.0",
     "react-router-dom": "^6.3.0",
+    "react-tag-input-component": "^2.0.2",
     "react-toastify": "^9.1.3",
     "text-case": "^1.0.9",
     "truncate": "^3.0.0",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 2d1eeb7d1..7224f2e9c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -36,6 +36,12 @@ const GeneralExportImport = lazy(() =>
   import("@/pages/GeneralSettings/ExportImport")
 );
 const GeneralSecurity = lazy(() => import("@/pages/GeneralSettings/Security"));
+const DataConnectors = lazy(() =>
+  import("@/pages/GeneralSettings/DataConnectors")
+);
+const DataConnectorSetup = lazy(() =>
+  import("@/pages/GeneralSettings/DataConnectors/Connectors")
+);
 const OnboardingFlow = lazy(() => import("@/pages/OnboardingFlow"));
 
 export default function App() {
@@ -103,6 +109,15 @@ export default function App() {
                 path="/settings/workspaces"
                 element={<ManagerRoute Component={AdminWorkspaces} />}
               />
+              <Route
+                path="/settings/data-connectors"
+                element={<ManagerRoute Component={DataConnectors} />}
+              />
+              <Route
+                path="/settings/data-connectors/:connector"
+                element={<ManagerRoute Component={DataConnectorSetup} />}
+              />
+
               {/* Onboarding Flow */}
               <Route path="/onboarding" element={<OnboardingFlow />} />
             </Routes>
diff --git a/frontend/src/components/DataConnectorOption/index.jsx b/frontend/src/components/DataConnectorOption/index.jsx
new file mode 100644
index 000000000..84af0ff1e
--- /dev/null
+++ b/frontend/src/components/DataConnectorOption/index.jsx
@@ -0,0 +1,39 @@
+import paths from "@/utils/paths";
+import ConnectorImages from "./media";
+
+export default function DataConnectorOption({ slug }) {
+  if (!DATA_CONNECTORS.hasOwnProperty(slug)) return null;
+  const { path, image, name, description, link } = DATA_CONNECTORS[slug];
+
+  return (
+    <a href={path}>
+      <label className="transition-all duration-300 inline-flex flex-col h-full w-60 cursor-pointer items-start justify-between rounded-2xl bg-preference-gradient border-2 border-transparent shadow-md px-5 py-4 text-white hover:bg-selected-preference-gradient hover:border-white/60 peer-checked:border-white peer-checked:border-opacity-90 peer-checked:bg-selected-preference-gradient">
+        <div className="flex items-center">
+          <img src={image} alt={name} className="h-10 w-10 rounded" />
+          <div className="ml-4 text-sm font-semibold">{name}</div>
+        </div>
+        <div className="mt-2 text-xs font-base text-white tracking-wide">
+          {description}
+        </div>
+        <a
+          href={link}
+          target="_blank"
+          className="mt-2 text-xs text-white font-medium underline"
+        >
+          {link}
+        </a>
+      </label>
+    </a>
+  );
+}
+
+export const DATA_CONNECTORS = {
+  github: {
+    name: "GitHub Repo",
+    path: paths.settings.dataConnectors.github(),
+    image: ConnectorImages.github,
+    description:
+      "Import an entire public or private Github repository in a single click.",
+    link: "https://github.com",
+  },
+};
diff --git a/frontend/src/components/DataConnectorOption/media/github.png b/frontend/src/components/DataConnectorOption/media/github.png
new file mode 100644
index 0000000000000000000000000000000000000000..835221bab5ec4ca19b03d0b9c25f4948b84a0cd7
GIT binary patch
literal 22064
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

literal 0
HcmV?d00001

diff --git a/frontend/src/components/DataConnectorOption/media/index.js b/frontend/src/components/DataConnectorOption/media/index.js
new file mode 100644
index 000000000..a339328ef
--- /dev/null
+++ b/frontend/src/components/DataConnectorOption/media/index.js
@@ -0,0 +1,5 @@
+import Github from "./github.png";
+const ConnectorImages = {
+  github: Github,
+};
+export default ConnectorImages;
diff --git a/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FileRow/index.jsx b/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FileRow/index.jsx
index b83d695a0..f83a9e34c 100644
--- a/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FileRow/index.jsx
+++ b/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FileRow/index.jsx
@@ -33,7 +33,7 @@ export default function FileRow({
     try {
       setLoading(true);
       setLoadingMessage("This may take a while for large documents");
-      await System.deleteDocument(`${folderName}/${item.name}`, item);
+      await System.deleteDocument(`${folderName}/${item.name}`);
       await fetchKeys(true);
     } catch (error) {
       console.error("Failed to delete the document:", error);
@@ -60,7 +60,7 @@ export default function FileRow({
         selected ? "bg-sky-500/20" : ""
       } ${expanded ? "bg-sky-500/10" : ""}`}`}
     >
-      <div className="col-span-4 flex gap-x-[4px] items-center">
+      <div className="pl-4 col-span-4 flex gap-x-[4px] items-center">
         <div
           className="w-3 h-3 rounded border-[1px] border-white flex justify-center items-center cursor-pointer"
           role="checkbox"
diff --git a/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FolderRow/index.jsx b/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FolderRow/index.jsx
index f2d5d5c71..c93a45cd3 100644
--- a/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FolderRow/index.jsx
+++ b/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/FolderRow/index.jsx
@@ -1,7 +1,8 @@
 import { useState } from "react";
 import FileRow from "../FileRow";
-import { CaretDown, FolderNotch } from "@phosphor-icons/react";
+import { CaretDown, FolderNotch, Trash } from "@phosphor-icons/react";
 import { middleTruncate } from "@/utils/directories";
+import System from "@/models/system";
 
 export default function FolderRow({
   item,
@@ -12,8 +13,32 @@ export default function FolderRow({
   fetchKeys,
   setLoading,
   setLoadingMessage,
+  autoExpanded = false,
 }) {
-  const [expanded, setExpanded] = useState(true);
+  const [expanded, setExpanded] = useState(autoExpanded);
+
+  const onTrashClick = async (event) => {
+    event.stopPropagation();
+    if (
+      !window.confirm(
+        "Are you sure you want to delete this folder?\nThis will require you to re-upload and re-embed it.\nAny documents in this folder will be removed from any workspace that is currently referencing it.\nThis action is not reversible."
+      )
+    ) {
+      return false;
+    }
+
+    try {
+      setLoading(true);
+      setLoadingMessage("This may take a while for large folders");
+      await System.deleteFolder(item.name);
+      await fetchKeys(true);
+    } catch (error) {
+      console.error("Failed to delete the document:", error);
+    }
+
+    if (selected) toggleSelection(item);
+    setLoading(false);
+  };
 
   const handleExpandClick = (event) => {
     event.stopPropagation();
@@ -30,7 +55,7 @@ export default function FolderRow({
       >
         <div className="col-span-4 flex gap-x-[4px] items-center">
           <div
-            className="w-3 h-3 rounded border-[1px] border-white flex justify-center items-center cursor-pointer"
+            className="shrink-0 w-3 h-3 rounded border-[1px] border-white flex justify-center items-center cursor-pointer"
             role="checkbox"
             aria-checked={selected}
             tabIndex={0}
@@ -46,7 +71,7 @@ export default function FolderRow({
             <CaretDown className="text-base font-bold w-4 h-4" />
           </div>
           <FolderNotch
-            className="text-base font-bold w-4 h-4 mr-[3px]"
+            className="shrink-0 text-base font-bold w-4 h-4 mr-[3px]"
             weight="fill"
           />
           <p className="whitespace-nowrap overflow-show">
@@ -56,7 +81,14 @@ export default function FolderRow({
         <p className="col-span-2 pl-3.5" />
         <p className="col-span-2 pl-3" />
         <p className="col-span-2 pl-2" />
-        <div className="col-span-2 flex justify-end items-center" />
+        <div className="col-span-2 flex justify-end items-center">
+          {item.name !== "custom-documents" && (
+            <Trash
+              onClick={onTrashClick}
+              className="text-base font-bold w-4 h-4 ml-2 flex-shrink-0 cursor-pointer"
+            />
+          )}
+        </div>
       </div>
       {expanded && (
         <div className="col-span-full">
diff --git a/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/index.jsx b/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/index.jsx
index 072010336..dcf625c5e 100644
--- a/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/index.jsx
+++ b/frontend/src/components/Modals/MangeWorkspace/Documents/Directory/index.jsx
@@ -106,6 +106,7 @@ export default function Directory({
                       isSelected={isSelected}
                       setLoading={setLoading}
                       setLoadingMessage={setLoadingMessage}
+                      autoExpanded={index === 0}
                     />
                   )
               )
diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx
index e599ee2b2..f4c9552e3 100644
--- a/frontend/src/components/SettingsSidebar/index.jsx
+++ b/frontend/src/components/SettingsSidebar/index.jsx
@@ -22,6 +22,7 @@ import {
   X,
   List,
   FileCode,
+  Plugs,
 } from "@phosphor-icons/react";
 import useUser from "@/hooks/useUser";
 import { USER_BACKGROUND_COLOR } from "@/utils/constants";
@@ -127,6 +128,11 @@ export default function SettingsSidebar() {
                       btnText="Vector Database"
                       icon={<Database className="h-5 w-5 flex-shrink-0" />}
                     />
+                    <Option
+                      href={paths.settings.dataConnectors.list()}
+                      btnText="Data Connectors"
+                      icon={<Plugs className="h-5 w-5 flex-shrink-0" />}
+                    />
                   </>
                 )}
 
diff --git a/frontend/src/components/Sidebar/IndexCount.jsx b/frontend/src/components/Sidebar/IndexCount.jsx
deleted file mode 100644
index 9e0e126c6..000000000
--- a/frontend/src/components/Sidebar/IndexCount.jsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import pluralize from "pluralize";
-import React, { useEffect, useState } from "react";
-import System from "@/models/system";
-import { numberWithCommas } from "@/utils/numbers";
-
-export default function IndexCount() {
-  const [indexes, setIndexes] = useState(null);
-  useEffect(() => {
-    async function indexCount() {
-      setIndexes(await System.totalIndexes());
-    }
-    indexCount();
-  }, []);
-
-  if (indexes === null || indexes === 0) {
-    return (
-      <div className="flex w-full items-center justify-end gap-x-2">
-        <div className="flex items-center gap-x-1 px-2 rounded-full">
-          <p className="text-slate-400 leading-tight text-sm"></p>
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <div className="flex w-full items-center justify-end gap-x-2">
-      <div className="flex items-center gap-x-1  px-2 rounded-full">
-        <p className="text-slate-400 leading-tight text-sm">
-          {numberWithCommas(indexes)} {pluralize("vector", indexes)}
-        </p>
-      </div>
-    </div>
-  );
-}
diff --git a/frontend/src/components/Sidebar/LLMStatus.jsx b/frontend/src/components/Sidebar/LLMStatus.jsx
deleted file mode 100644
index 733dcb1e7..000000000
--- a/frontend/src/components/Sidebar/LLMStatus.jsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { WarningCircle, Circle } from "@phosphor-icons/react";
-import System from "@/models/system";
-
-export default function LLMStatus() {
-  const [status, setStatus] = useState(null);
-  useEffect(() => {
-    async function checkPing() {
-      setStatus(await System.ping());
-    }
-    checkPing();
-  }, []);
-
-  if (status === null) {
-    return (
-      <div className="flex w-full items-center justify-start gap-x-2">
-        <p className="text-slate-400 leading-loose text-sm">LLM</p>
-        <div className="flex items-center gap-x-1 border border-slate-400 px-2 rounded-full">
-          <p className="text-slate-400 leading-tight text-sm">unknown</p>
-          <Circle className="h-3 w-3 stroke-slate-700 fill-slate-400 animate-pulse" />
-        </div>
-      </div>
-    );
-  }
-
-  // TODO: add modal or toast on click to identify why this is broken
-  // need to likely start server.
-  if (status === false) {
-    return (
-      <div className="flex w-full items-center justify-end gap-x-2">
-        <p className="text-slate-400 leading-loose text-sm">LLM</p>
-        <div className="flex items-center gap-x-1 border border-red-400 px-2 bg-red-200 rounded-full">
-          <p className="text-red-700 leading-tight text-sm">offline</p>
-          <WarningCircle className="h-3 w-3 stroke-red-100 fill-red-400" />
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <div className="flex w-full items-center justify-end gap-x-2">
-      <p className="text-slate-400 leading-loose text-sm">LLM</p>
-      <div className="flex items-center gap-x-1 border border-slate-400 px-2 rounded-full">
-        <p className="text-slate-400 leading-tight text-sm">online</p>
-        <Circle className="h-3 w-3 stroke-green-100 fill-green-400 animate-pulse" />
-      </div>
-    </div>
-  );
-}
diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx
index ac92f4830..58af4048b 100644
--- a/frontend/src/components/Sidebar/index.jsx
+++ b/frontend/src/components/Sidebar/index.jsx
@@ -71,25 +71,6 @@ export default function Sidebar() {
               <ActiveWorkspaces />
             </div>
             <div className="flex flex-col flex-grow justify-end mb-2">
-              {/* <div className="flex flex-col gap-y-2">
-                <div className="w-full flex items-center justify-between">
-                  <LLMStatus />
-                  <IndexCount />
-                </div>
-                <a
-                  href={paths.feedback()}
-                  target="_blank"
-                  className="flex flex-grow w-[100%] h-[36px] gap-x-2 py-[5px] px-4 border border-transparent rounded-lg text-slate-200 justify-center items-center bg-stone-800 hover:bg-stone-900"
-                >
-                  <AtSign className="h-4 w-4" />
-                  <p className="text-slate-200 text-xs leading-loose font-semibold">
-                    Feedback form
-                  </p>
-                </a>
-                <ManagedHosting />
-                <LogoutButton />
-              </div> */}
-
               {/* Footer */}
               <div className="flex justify-center mt-2">
                 <div className="flex space-x-4">
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f0f04bbcc..a7aef9a7e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -385,3 +385,7 @@ dialog::backdrop {
     @apply border-blue-500 bg-blue-400/10 text-blue-800;
   }
 }
+
+.rti--container {
+  @apply !bg-zinc-900 !text-white !placeholder-white !placeholder-opacity-60 !text-sm !rounded-lg !p-2.5;
+}
diff --git a/frontend/src/models/dataConnector.js b/frontend/src/models/dataConnector.js
new file mode 100644
index 000000000..45d575024
--- /dev/null
+++ b/frontend/src/models/dataConnector.js
@@ -0,0 +1,47 @@
+import { API_BASE } from "@/utils/constants";
+import { baseHeaders } from "@/utils/request";
+import showToast from "@/utils/toast";
+
+const DataConnector = {
+  github: {
+    branches: async ({ repo, accessToken }) => {
+      return await fetch(`${API_BASE}/ext/github/branches`, {
+        method: "POST",
+        headers: baseHeaders(),
+        cache: "force-cache",
+        body: JSON.stringify({ repo, accessToken }),
+      })
+        .then((res) => res.json())
+        .then((res) => {
+          if (!res.success) throw new Error(res.reason);
+          return res.data;
+        })
+        .then((data) => {
+          return { branches: data?.branches || [], error: null };
+        })
+        .catch((e) => {
+          console.error(e);
+          showToast(e.message, "error");
+          return { branches: [], error: e.message };
+        });
+    },
+    collect: async function ({ repo, accessToken, branch, ignorePaths = [] }) {
+      return await fetch(`${API_BASE}/ext/github/repo`, {
+        method: "POST",
+        headers: baseHeaders(),
+        body: JSON.stringify({ repo, accessToken, branch, ignorePaths }),
+      })
+        .then((res) => res.json())
+        .then((res) => {
+          if (!res.success) throw new Error(res.reason);
+          return { data: res.data, error: null };
+        })
+        .catch((e) => {
+          console.error(e);
+          return { data: null, error: e.message };
+        });
+    },
+  },
+};
+
+export default DataConnector;
diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js
index 79c203d94..9b71a6055 100644
--- a/frontend/src/models/system.js
+++ b/frontend/src/models/system.js
@@ -1,5 +1,6 @@
 import { API_BASE, AUTH_TIMESTAMP } from "@/utils/constants";
 import { baseHeaders } from "@/utils/request";
+import DataConnector from "./dataConnector";
 
 const System = {
   ping: async function () {
@@ -133,11 +134,23 @@ const System = {
         return false;
       });
   },
-  deleteDocument: async (name, meta) => {
+  deleteDocument: async (name) => {
     return await fetch(`${API_BASE}/system/remove-document`, {
       method: "DELETE",
       headers: baseHeaders(),
-      body: JSON.stringify({ name, meta }),
+      body: JSON.stringify({ name }),
+    })
+      .then((res) => res.ok)
+      .catch((e) => {
+        console.error(e);
+        return false;
+      });
+  },
+  deleteFolder: async (name) => {
+    return await fetch(`${API_BASE}/system/remove-folder`, {
+      method: "DELETE",
+      headers: baseHeaders(),
+      body: JSON.stringify({ name }),
     })
       .then((res) => res.ok)
       .catch((e) => {
@@ -431,6 +444,7 @@ const System = {
         return { success: false, error: e.message };
       });
   },
+  dataConnectors: DataConnector,
 };
 
 export default System;
diff --git a/frontend/src/pages/GeneralSettings/DataConnectors/Connectors/Github/index.jsx b/frontend/src/pages/GeneralSettings/DataConnectors/Connectors/Github/index.jsx
new file mode 100644
index 000000000..fdcc8cb57
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/DataConnectors/Connectors/Github/index.jsx
@@ -0,0 +1,294 @@
+import React, { useEffect, useState } from "react";
+import Sidebar, { SidebarMobileHeader } from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import { DATA_CONNECTORS } from "@/components/DataConnectorOption";
+import System from "@/models/system";
+import { Info } from "@phosphor-icons/react/dist/ssr";
+import showToast from "@/utils/toast";
+import pluralize from "pluralize";
+import { TagsInput } from "react-tag-input-component";
+
+const DEFAULT_BRANCHES = ["main", "master"];
+export default function GithubConnectorSetup() {
+  const { image } = DATA_CONNECTORS.github;
+  const [loading, setLoading] = useState(false);
+  const [repo, setRepo] = useState(null);
+  const [accessToken, setAccessToken] = useState(null);
+  const [ignores, setIgnores] = useState([]);
+
+  const [settings, setSettings] = useState({
+    repo: null,
+    accessToken: null,
+  });
+
+  const handleSubmit = async (e) => {
+    e.preventDefault();
+    const form = new FormData(e.target);
+
+    try {
+      setLoading(true);
+      showToast(
+        "Fetching all files for repo - this may take a while.",
+        "info",
+        { clear: true, autoClose: false }
+      );
+      const { data, error } = await System.dataConnectors.github.collect({
+        repo: form.get("repo"),
+        accessToken: form.get("accessToken"),
+        branch: form.get("branch"),
+        ignorePaths: ignores,
+      });
+
+      if (!!error) {
+        showToast(error, "error", { clear: true });
+        setLoading(false);
+        return;
+      }
+
+      showToast(
+        `${data.files} ${pluralize("file", data.files)} collected from ${
+          data.author
+        }/${data.repo}:${data.branch}. Output folder is ${data.destination}.`,
+        "success",
+        { clear: true }
+      );
+      e.target.reset();
+      setLoading(false);
+      return;
+    } catch (e) {
+      console.error(e);
+      showToast(e.message, "error", { clear: true });
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="w-screen h-screen overflow-hidden bg-sidebar flex">
+      {!isMobile && <Sidebar />}
+      <div
+        style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
+        className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent"
+      >
+        {isMobile && <SidebarMobileHeader />}
+        <div className="flex w-full">
+          <div className="flex flex-col w-full px-1 md:px-20 md:py-12 py-16">
+            <div className="flex w-full gap-x-4 items-center  pb-6 border-white border-b-2 border-opacity-10">
+              <img src={image} alt="Github" className="rounded-lg h-16 w-16" />
+              <div className="w-full flex flex-col gap-y-1">
+                <div className="items-center flex gap-x-4">
+                  <p className="text-2xl font-semibold text-white">
+                    Import GitHub Repository
+                  </p>
+                </div>
+                <p className="text-sm font-base text-white text-opacity-60">
+                  Import all files from a public or private Github repository
+                  and have its files be available in your workspace.
+                </p>
+              </div>
+            </div>
+
+            <form className="w-full" onSubmit={handleSubmit}>
+              {!accessToken && (
+                <div className="flex flex-col gap-y-1 py-4 ">
+                  <div className="flex flex-col w-fit gap-y-2 bg-blue-600/20 rounded-lg px-4 py-2">
+                    <div className="flex items-center gap-x-2">
+                      <Info size={20} className="shrink-0 text-blue-400" />
+                      <p className="text-blue-400 text-sm">
+                        Trying to collect a GitHub repo without a{" "}
+                        <a
+                          href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
+                          rel="noreferrer"
+                          target="_blank"
+                          className="underline"
+                        >
+                          Personal Access Token
+                        </a>{" "}
+                        will fail to collect all files due to GitHub API limits.
+                      </p>
+                    </div>
+                    <a
+                      href="https://github.com/settings/personal-access-tokens/new"
+                      rel="noreferrer"
+                      target="_blank"
+                      className="text-blue-400 hover:underline"
+                    >
+                      Create a temporary Access Token for this data connector
+                      &rarr;
+                    </a>
+                  </div>
+                </div>
+              )}
+
+              <div className="w-full flex flex-col py-2">
+                <div className="w-full flex items-center gap-4">
+                  <div className="flex flex-col w-60">
+                    <div className="flex flex-col gap-y-1 mb-4">
+                      <label className="text-white text-sm font-semibold block">
+                        GitHub Repo URL
+                      </label>
+                      <p className="text-xs text-zinc-300">
+                        Url of the GitHub repo you wish to collect.
+                      </p>
+                    </div>
+                    <input
+                      type="url"
+                      name="repo"
+                      className="bg-zinc-900 text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white block w-full p-2.5"
+                      placeholder="https://github.com/Mintplex-Labs/anything-llm"
+                      required={true}
+                      autoComplete="off"
+                      onChange={(e) => setRepo(e.target.value)}
+                      onBlur={() => setSettings({ ...settings, repo })}
+                      spellCheck={false}
+                    />
+                  </div>
+                  <div className="flex flex-col w-60">
+                    <div className="flex flex-col gap-y-1 mb-4">
+                      <label className="text-white text-sm block flex gap-x-2 items-center">
+                        <p className="font-semibold ">Github Access Token</p>{" "}
+                        <p className="text-xs text-zinc-300 font-base!">
+                          <i>optional</i>
+                        </p>
+                      </label>
+                      <p className="text-xs text-zinc-300 flex gap-x-2">
+                        Access Token to prevent rate limiting.
+                      </p>
+                    </div>
+                    <input
+                      type="text"
+                      name="accessToken"
+                      className="bg-zinc-900 text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white block w-full p-2.5"
+                      placeholder="github_pat_1234_abcdefg"
+                      required={false}
+                      autoComplete="off"
+                      spellCheck={false}
+                      onChange={(e) => setAccessToken(e.target.value)}
+                      onBlur={() => setSettings({ ...settings, accessToken })}
+                    />
+                  </div>
+                  <GitHubBranchSelection
+                    repo={settings.repo}
+                    accessToken={settings.accessToken}
+                  />
+                </div>
+
+                <div className="flex flex-col w-1/2 py-4">
+                  <div className="flex flex-col gap-y-1 mb-4">
+                    <label className="text-white text-sm block flex gap-x-2 items-center">
+                      <p className="font-semibold ">File Ignores</p>
+                    </label>
+                    <p className="text-xs text-zinc-300 flex gap-x-2">
+                      List in .gitignore format to ignore specific files during
+                      collection. Press enter after each entry you want to save.
+                    </p>
+                  </div>
+                  <TagsInput
+                    value={ignores}
+                    onChange={setIgnores}
+                    name="ignores"
+                    placeholder="!*.js, images/*, .DS_Store, bin/*"
+                    classNames={{
+                      tag: "bg-blue-300/10 text-zinc-800 m-1",
+                      input:
+                        "flex bg-zinc-900 text-white placeholder-white placeholder-opacity-60 text-sm rounded-lg focus:border-white p-2.5",
+                    }}
+                  />
+                </div>
+              </div>
+
+              <div className="flex flex-col gap-y-2 w-fit">
+                <button
+                  type="submit"
+                  disabled={loading}
+                  className="mt-2 text-lg w-fit border border-slate-200 px-4 py-1 rounded-lg text-slate-200 items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 disabled:bg-slate-200 disabled:text-slate-800"
+                >
+                  {loading
+                    ? "Collecting files..."
+                    : "Collect all files from GitHub repo"}
+                </button>
+                {loading && (
+                  <p className="text-xs text-zinc-300">
+                    Once complete, all files will be available for embedding
+                    into workspaces in the document picker.
+                  </p>
+                )}
+              </div>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function GitHubBranchSelection({ repo, accessToken }) {
+  const [allBranches, setAllBranches] = useState(DEFAULT_BRANCHES);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    async function fetchAllBranches() {
+      if (!repo) {
+        setAllBranches(DEFAULT_BRANCHES);
+        setLoading(false);
+        return;
+      }
+
+      setLoading(true);
+      const { branches } = await System.dataConnectors.github.branches({
+        repo,
+        accessToken,
+      });
+      setAllBranches(branches.length > 0 ? branches : DEFAULT_BRANCHES);
+      setLoading(false);
+    }
+    fetchAllBranches();
+  }, [repo, accessToken]);
+
+  if (loading) {
+    return (
+      <div className="flex flex-col w-60">
+        <div className="flex flex-col gap-y-1 mb-4">
+          <label className="text-white text-sm font-semibold block">
+            Branch
+          </label>
+          <p className="text-xs text-zinc-300">
+            Branch you wish to collect files of
+          </p>
+        </div>
+        <select
+          name="branch"
+          required={true}
+          className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5"
+        >
+          <option disabled={true} selected={true}>
+            -- loading available models --
+          </option>
+        </select>
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-col w-60">
+      <div className="flex flex-col gap-y-1 mb-4">
+        <label className="text-white text-sm font-semibold block">Branch</label>
+        <p className="text-xs text-zinc-300">
+          Branch you wish to collect files of
+        </p>
+      </div>
+      <select
+        name="branch"
+        required={true}
+        className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5"
+      >
+        {allBranches.map((branch) => {
+          return (
+            <option key={branch} value={branch}>
+              {branch}
+            </option>
+          );
+        })}
+      </select>
+    </div>
+  );
+}
diff --git a/frontend/src/pages/GeneralSettings/DataConnectors/Connectors/index.jsx b/frontend/src/pages/GeneralSettings/DataConnectors/Connectors/index.jsx
new file mode 100644
index 000000000..cbd66f08a
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/DataConnectors/Connectors/index.jsx
@@ -0,0 +1,19 @@
+import paths from "@/utils/paths";
+import { lazy } from "react";
+import { useParams } from "react-router-dom";
+const Github = lazy(() => import("./Github"));
+
+const CONNECTORS = {
+  github: Github,
+};
+
+export default function DataConnectorSetup() {
+  const { connector } = useParams();
+  if (!connector || !CONNECTORS.hasOwnProperty(connector)) {
+    window.location = paths.home();
+    return;
+  }
+
+  const Page = CONNECTORS[connector];
+  return <Page />;
+}
diff --git a/frontend/src/pages/GeneralSettings/DataConnectors/index.jsx b/frontend/src/pages/GeneralSettings/DataConnectors/index.jsx
new file mode 100644
index 000000000..76dc13d0a
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/DataConnectors/index.jsx
@@ -0,0 +1,38 @@
+import React from "react";
+import Sidebar, { SidebarMobileHeader } from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import DataConnectorOption from "@/components/DataConnectorOption";
+
+export default function DataConnectors() {
+  return (
+    <div className="w-screen h-screen overflow-hidden bg-sidebar flex">
+      {!isMobile && <Sidebar />}
+      <div
+        style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
+        className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[26px] bg-main-gradient w-full h-full overflow-y-scroll border-4 border-accent"
+      >
+        {isMobile && <SidebarMobileHeader />}
+        <div className="flex w-full">
+          <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">
+                  Data Connectors
+                </p>
+              </div>
+              <p className="text-sm font-base text-white text-opacity-60">
+                Verified data connectors allow you to add more content to your
+                AnythingLLM workspaces with no custom code or complexity.
+                <br />
+                Guaranteed to work with your AnythingLLM instance.
+              </p>
+            </div>
+            <div className="py-4 w-full flex md:flex-wrap overflow-x-scroll gap-4 max-w-full">
+              <DataConnectorOption slug="github" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js
index bfeb8b1bd..c21c1500b 100644
--- a/frontend/src/utils/paths.js
+++ b/frontend/src/utils/paths.js
@@ -76,5 +76,13 @@ export default {
     apiKeys: () => {
       return "/settings/api-keys";
     },
+    dataConnectors: {
+      list: () => {
+        return "/settings/data-connectors";
+      },
+      github: () => {
+        return "/settings/data-connectors/github";
+      },
+    },
   },
 };
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index fdb7aae69..1d0639f44 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -2061,6 +2061,11 @@ react-router@6.12.1:
   dependencies:
     "@remix-run/router" "1.6.3"
 
+react-tag-input-component@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/react-tag-input-component/-/react-tag-input-component-2.0.2.tgz#f62f013c6a535141dd1c6c3a88858223170150f1"
+  integrity sha512-dydI9luVwwv9vrjE5u1TTnkcOVkOVL6mhFti8r6hLi78V2F2EKWQOLptURz79UYbDHLSk6tnbvGl8FE+sMpADg==
+
 react-toastify@^9.1.3:
   version "9.1.3"
   resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-9.1.3.tgz#1e798d260d606f50e0fab5ee31daaae1d628c5ff"
diff --git a/server/endpoints/extensions/index.js b/server/endpoints/extensions/index.js
new file mode 100644
index 000000000..fc545ce3c
--- /dev/null
+++ b/server/endpoints/extensions/index.js
@@ -0,0 +1,53 @@
+const { Telemetry } = require("../../models/telemetry");
+const {
+  forwardExtensionRequest,
+} = require("../../utils/files/documentProcessor");
+const {
+  flexUserRoleValid,
+} = require("../../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../../utils/middleware/validatedRequest");
+
+function extensionEndpoints(app) {
+  if (!app) return;
+
+  app.post(
+    "/ext/github/branches",
+    [validatedRequest, flexUserRoleValid],
+    async (request, response) => {
+      try {
+        const responseFromProcessor = await forwardExtensionRequest({
+          endpoint: "/ext/github-repo/branches",
+          method: "POST",
+          body: request.body,
+        });
+        response.status(200).json(responseFromProcessor);
+      } catch (e) {
+        console.error(e);
+        response.sendStatus(500).end();
+      }
+    }
+  );
+
+  app.post(
+    "/ext/github/repo",
+    [validatedRequest, flexUserRoleValid],
+    async (request, response) => {
+      try {
+        const responseFromProcessor = await forwardExtensionRequest({
+          endpoint: "/ext/github-repo",
+          method: "POST",
+          body: request.body,
+        });
+        await Telemetry.sendTelemetry("extension_invoked", {
+          type: "github_repo",
+        });
+        response.status(200).json(responseFromProcessor);
+      } catch (e) {
+        console.error(e);
+        response.sendStatus(500).end();
+      }
+    }
+  );
+}
+
+module.exports = { extensionEndpoints };
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index 790305cff..fe54f6d2b 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -7,7 +7,7 @@ const {
   checkProcessorAlive,
   acceptedFileTypes,
 } = require("../utils/files/documentProcessor");
-const { purgeDocument } = require("../utils/files/purgeDocument");
+const { purgeDocument, purgeFolder } = require("../utils/files/purgeDocument");
 const { getVectorDbClass } = require("../utils/helpers");
 const { updateENV, dumpENV } = require("../utils/helpers/updateENV");
 const {
@@ -196,8 +196,23 @@ function systemEndpoints(app) {
     [validatedRequest],
     async (request, response) => {
       try {
-        const { name, meta } = reqBody(request);
-        await purgeDocument(name, meta);
+        const { name } = reqBody(request);
+        await purgeDocument(name);
+        response.sendStatus(200).end();
+      } catch (e) {
+        console.log(e.message, e);
+        response.sendStatus(500).end();
+      }
+    }
+  );
+
+  app.delete(
+    "/system/remove-folder",
+    [validatedRequest],
+    async (request, response) => {
+      try {
+        const { name } = reqBody(request);
+        await purgeFolder(name);
         response.sendStatus(200).end();
       } catch (e) {
         console.log(e.message, e);
diff --git a/server/index.js b/server/index.js
index d6999dd35..1a106053c 100644
--- a/server/index.js
+++ b/server/index.js
@@ -18,6 +18,7 @@ const { utilEndpoints } = require("./endpoints/utils");
 const { Telemetry } = require("./models/telemetry");
 const { developerEndpoints } = require("./endpoints/api");
 const setupTelemetry = require("./utils/telemetry");
+const { extensionEndpoints } = require("./endpoints/extensions");
 const app = express();
 const apiRouter = express.Router();
 const FILE_LIMIT = "3GB";
@@ -34,6 +35,7 @@ app.use(
 
 app.use("/api", apiRouter);
 systemEndpoints(apiRouter);
+extensionEndpoints(apiRouter);
 workspaceEndpoints(apiRouter);
 chatEndpoints(apiRouter);
 adminEndpoints(apiRouter);
diff --git a/server/utils/files/documentProcessor.js b/server/utils/files/documentProcessor.js
index ea47a1582..5239a8708 100644
--- a/server/utils/files/documentProcessor.js
+++ b/server/utils/files/documentProcessor.js
@@ -59,9 +59,32 @@ async function processLink(link = "") {
     });
 }
 
+// We will not ever expose the document processor to the frontend API so instead we relay
+// all requests through the server. You can use this function to directly expose a specific endpoint
+// on the document processor.
+async function forwardExtensionRequest({ endpoint, method, body }) {
+  return await fetch(`${PROCESSOR_API}${endpoint}`, {
+    method,
+    body, // Stringified JSON!
+    headers: {
+      "Content-Type": "application/json",
+    },
+  })
+    .then((res) => {
+      if (!res.ok) throw new Error("Response could not be completed");
+      return res.json();
+    })
+    .then((res) => res)
+    .catch((e) => {
+      console.log(e.message);
+      return { success: false, data: {}, reason: e.message };
+    });
+}
+
 module.exports = {
   checkProcessorAlive,
   processDocument,
   processLink,
   acceptedFileTypes,
+  forwardExtensionRequest,
 };
diff --git a/server/utils/files/index.js b/server/utils/files/index.js
index 83505f8b4..c86221876 100644
--- a/server/utils/files/index.js
+++ b/server/utils/files/index.js
@@ -144,18 +144,14 @@ async function storeVectorResult(vectorData = [], filename = null) {
 // Purges a file from the documents/ folder.
 async function purgeSourceDocument(filename = null) {
   if (!filename) return;
-  console.log(`Purging document of ${filename}.`);
+  console.log(`Purging source document of ${filename}.`);
 
   const filePath =
     process.env.NODE_ENV === "development"
       ? path.resolve(__dirname, `../../storage/documents`, filename)
       : path.resolve(process.env.STORAGE_DIR, `documents`, filename);
 
-  if (!fs.existsSync(filePath)) {
-    console.log(`Could not located cachefile for ${filename}`, filePath);
-    return;
-  }
-
+  if (!fs.existsSync(filePath)) return;
   fs.rmSync(filePath);
   return;
 }
@@ -163,7 +159,7 @@ async function purgeSourceDocument(filename = null) {
 // Purges a vector-cache file from the vector-cache/ folder.
 async function purgeVectorCache(filename = null) {
   if (!filename) return;
-  console.log(`Purging cached vectorized results of ${filename}.`);
+  console.log(`Purging vector-cache of ${filename}.`);
 
   const digest = uuidv5(filename, uuidv5.URL);
   const filePath =
@@ -171,11 +167,7 @@ async function purgeVectorCache(filename = null) {
       ? path.resolve(__dirname, `../../storage/vector-cache`, `${digest}.json`)
       : path.resolve(process.env.STORAGE_DIR, `vector-cache`, `${digest}.json`);
 
-  if (!fs.existsSync(filePath)) {
-    console.log(`Could not located cache file for ${filename}`, filePath);
-    return;
-  }
-
+  if (!fs.existsSync(filePath)) return;
   fs.rmSync(filePath);
   return;
 }
diff --git a/server/utils/files/purgeDocument.js b/server/utils/files/purgeDocument.js
index a584a4261..27fe14710 100644
--- a/server/utils/files/purgeDocument.js
+++ b/server/utils/files/purgeDocument.js
@@ -1,8 +1,11 @@
+const fs = require("fs");
+const path = require("path");
+
 const { purgeVectorCache, purgeSourceDocument } = require(".");
 const { Document } = require("../../models/documents");
 const { Workspace } = require("../../models/workspace");
 
-async function purgeDocument(filename, meta) {
+async function purgeDocument(filename) {
   const workspaces = await Workspace.where();
   for (const workspace of workspaces) {
     await Document.removeDocuments(workspace, [filename]);
@@ -12,6 +15,45 @@ async function purgeDocument(filename, meta) {
   return;
 }
 
+async function purgeFolder(folderName) {
+  if (folderName === "custom-documents") return;
+  const documentsFolder =
+    process.env.NODE_ENV === "development"
+      ? path.resolve(__dirname, `../../storage/documents`)
+      : path.resolve(process.env.STORAGE_DIR, `documents`);
+
+  const folderPath = path.resolve(documentsFolder, folderName);
+  const filenames = fs
+    .readdirSync(folderPath)
+    .map((file) => path.join(folderName, file));
+  const workspaces = await Workspace.where();
+
+  const purgePromises = [];
+  // Remove associated Vector-cache files
+  for (const filename of filenames) {
+    const rmVectorCache = () =>
+      new Promise((resolve) =>
+        purgeVectorCache(filename).then(() => resolve(true))
+      );
+    purgePromises.push(rmVectorCache);
+  }
+
+  // Remove workspace document associations
+  for (const workspace of workspaces) {
+    const rmWorkspaceDoc = () =>
+      new Promise((resolve) =>
+        Document.removeDocuments(workspace, filenames).then(() => resolve(true))
+      );
+    purgePromises.push(rmWorkspaceDoc);
+  }
+
+  await Promise.all(purgePromises.flat().map((f) => f()));
+  fs.rmSync(folderPath, { recursive: true }); // Delete root document and source files.
+
+  return;
+}
+
 module.exports = {
   purgeDocument,
+  purgeFolder,
 };