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 + → + </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, };