+ DuckDuckGo is ready to use without any additional configuration.
+
+ >
+ );
+}
+
+export function ExaSearchOptions({ settings }) {
+ return (
+ <>
+
+
+
+
+
+ Live web search and browsing
+
+
+ toggleSkill(skill)}
+ />
+
+
+
+
+
+
+ Enable your agent to search the web to answer your questions by
+ connecting to a web-search (SERP) provider. Web search during agent
+ sessions will not work until this is set up.
+
+
+
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredResults.map((provider) => {
+ return (
+ updateChoice(provider.value)}
+ />
+ );
+ })}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedSearchProviderObject.name}
+
+
+ {selectedSearchProviderObject.description}
+
+
+
+
+
+ )}
+
+ {selectedProvider !== "none" && (
+
+ {selectedSearchProviderObject.options(settings)}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Agents/index.jsx b/frontend/src/pages/Admin/Agents/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a322be5abf0760a8fe209daf55eda55e1c704bb5
--- /dev/null
+++ b/frontend/src/pages/Admin/Agents/index.jsx
@@ -0,0 +1,698 @@
+import { useEffect, useRef, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import Admin from "@/models/admin";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import {
+ CaretLeft,
+ CaretRight,
+ Plug,
+ Robot,
+ Hammer,
+ FlowArrow,
+} from "@phosphor-icons/react";
+import ContextualSaveBar from "@/components/ContextualSaveBar";
+import { castToType } from "@/utils/types";
+import { FullScreenLoader } from "@/components/Preloader";
+import { defaultSkills, configurableSkills } from "./skills";
+import { DefaultBadge } from "./Badges/default";
+import ImportedSkillList from "./Imported/SkillList";
+import ImportedSkillConfig from "./Imported/ImportedSkillConfig";
+import { Tooltip } from "react-tooltip";
+import AgentFlowsList from "./AgentFlows";
+import FlowPanel from "./AgentFlows/FlowPanel";
+import { MCPServersList, MCPServerHeader } from "./MCPServers";
+import ServerPanel from "./MCPServers/ServerPanel";
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+import AgentFlows from "@/models/agentFlows";
+
+export default function AdminAgents() {
+ const formEl = useRef(null);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [settings, setSettings] = useState({});
+ const [selectedSkill, setSelectedSkill] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [showSkillModal, setShowSkillModal] = useState(false);
+
+ const [agentSkills, setAgentSkills] = useState([]);
+ const [importedSkills, setImportedSkills] = useState([]);
+ const [disabledAgentSkills, setDisabledAgentSkills] = useState([]);
+
+ const [agentFlows, setAgentFlows] = useState([]);
+ const [selectedFlow, setSelectedFlow] = useState(null);
+ const [activeFlowIds, setActiveFlowIds] = useState([]);
+
+ // MCP Servers are lazy loaded to not block the UI thread
+ const [mcpServers, setMcpServers] = useState([]);
+ const [selectedMcpServer, setSelectedMcpServer] = useState(null);
+
+ // Alert user if they try to leave the page with unsaved changes
+ useEffect(() => {
+ const handleBeforeUnload = (event) => {
+ if (hasChanges) {
+ event.preventDefault();
+ event.returnValue = "";
+ }
+ };
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => {
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ };
+ }, [hasChanges]);
+
+ useEffect(() => {
+ async function fetchSettings() {
+ const _settings = await System.keys();
+ const _preferences = await Admin.systemPreferencesByFields([
+ "disabled_agent_skills",
+ "default_agent_skills",
+ "imported_agent_skills",
+ "active_agent_flows",
+ ]);
+ const { flows = [] } = await AgentFlows.listFlows();
+
+ setSettings({ ..._settings, preferences: _preferences.settings } ?? {});
+ setAgentSkills(_preferences.settings?.default_agent_skills ?? []);
+ setDisabledAgentSkills(
+ _preferences.settings?.disabled_agent_skills ?? []
+ );
+ setImportedSkills(_preferences.settings?.imported_agent_skills ?? []);
+ setActiveFlowIds(_preferences.settings?.active_agent_flows ?? []);
+ setAgentFlows(flows);
+ setLoading(false);
+ }
+ fetchSettings();
+ }, []);
+
+ const toggleDefaultSkill = (skillName) => {
+ setDisabledAgentSkills((prev) => {
+ const updatedSkills = prev.includes(skillName)
+ ? prev.filter((name) => name !== skillName)
+ : [...prev, skillName];
+ setHasChanges(true);
+ return updatedSkills;
+ });
+ };
+
+ const toggleAgentSkill = (skillName) => {
+ setAgentSkills((prev) => {
+ const updatedSkills = prev.includes(skillName)
+ ? prev.filter((name) => name !== skillName)
+ : [...prev, skillName];
+ setHasChanges(true);
+ return updatedSkills;
+ });
+ };
+
+ const toggleFlow = (flowId) => {
+ setActiveFlowIds((prev) => {
+ const updatedFlows = prev.includes(flowId)
+ ? prev.filter((id) => id !== flowId)
+ : [...prev, flowId];
+ return updatedFlows;
+ });
+ };
+
+ const toggleMCP = (serverName) => {
+ setMcpServers((prev) => {
+ return prev.map((server) => {
+ if (server.name !== serverName) return server;
+ return { ...server, running: !server.running };
+ });
+ });
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const data = {
+ workspace: {},
+ system: {},
+ env: {},
+ };
+
+ const form = new FormData(formEl.current);
+ for (var [key, value] of form.entries()) {
+ if (key.startsWith("system::")) {
+ const [_, label] = key.split("system::");
+ data.system[label] = String(value);
+ continue;
+ }
+
+ if (key.startsWith("env::")) {
+ const [_, label] = key.split("env::");
+ data.env[label] = String(value);
+ continue;
+ }
+ data.workspace[key] = castToType(key, value);
+ }
+
+ const { success } = await Admin.updateSystemPreferences(data.system);
+ await System.updateSystem(data.env);
+
+ if (success) {
+ const _settings = await System.keys();
+ const _preferences = await Admin.systemPreferencesByFields([
+ "disabled_agent_skills",
+ "default_agent_skills",
+ "imported_agent_skills",
+ ]);
+ setSettings({ ..._settings, preferences: _preferences.settings } ?? {});
+ setAgentSkills(_preferences.settings?.default_agent_skills ?? []);
+ setDisabledAgentSkills(
+ _preferences.settings?.disabled_agent_skills ?? []
+ );
+ setImportedSkills(_preferences.settings?.imported_agent_skills ?? []);
+ showToast(`Agent preferences saved successfully.`, "success", {
+ clear: true,
+ });
+ } else {
+ showToast(`Agent preferences failed to save.`, "error", { clear: true });
+ }
+
+ setHasChanges(false);
+ };
+
+ let SelectedSkillComponent = null;
+ if (selectedFlow) {
+ SelectedSkillComponent = FlowPanel;
+ } else if (selectedMcpServer) {
+ SelectedSkillComponent = ServerPanel;
+ } else if (selectedSkill?.imported) {
+ SelectedSkillComponent = ImportedSkillConfig;
+ } else if (configurableSkills[selectedSkill]) {
+ SelectedSkillComponent = configurableSkills[selectedSkill]?.component;
+ } else {
+ SelectedSkillComponent = defaultSkills[selectedSkill]?.component;
+ }
+
+ // Update the click handlers to clear the other selection
+ const handleDefaultSkillClick = (skill) => {
+ setSelectedFlow(null);
+ setSelectedMcpServer(null);
+ setSelectedSkill(skill);
+ if (isMobile) setShowSkillModal(true);
+ };
+
+ const handleSkillClick = (skill) => {
+ setSelectedFlow(null);
+ setSelectedMcpServer(null);
+ setSelectedSkill(skill);
+ if (isMobile) setShowSkillModal(true);
+ };
+
+ const handleFlowClick = (flow) => {
+ setSelectedSkill(null);
+ setSelectedMcpServer(null);
+ setSelectedFlow(flow);
+ if (isMobile) setShowSkillModal(true);
+ };
+
+ const handleMCPClick = (server) => {
+ setSelectedSkill(null);
+ setSelectedFlow(null);
+ setSelectedMcpServer(server);
+ if (isMobile) setShowSkillModal(true);
+ };
+
+ const handleFlowDelete = (flowId) => {
+ setSelectedFlow(null);
+ setActiveFlowIds((prev) => prev.filter((id) => id !== flowId));
+ setAgentFlows((prev) => prev.filter((flow) => flow.uuid !== flowId));
+ };
+
+ const handleMCPServerDelete = (serverName) => {
+ setSelectedMcpServer(null);
+ setMcpServers((prev) =>
+ prev.filter((server) => server.name !== serverName)
+ );
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
setHasChanges(false)}
+ handleSubmit={handleSubmit}
+ >
+
+
+ );
+ }
+
+ return (
+
setHasChanges(false)}
+ handleSubmit={handleSubmit}
+ >
+
+
+ );
+}
+
+function SkillLayout({ children, hasChanges, handleSubmit, handleCancel }) {
+ return (
+
+ );
+}
+
+function SkillList({
+ isDefault = false,
+ skills = [],
+ selectedSkill = null,
+ handleClick = null,
+ activeSkills = [],
+}) {
+ if (skills.length === 0) return null;
+
+ return (
+ <>
+
+ {Object.entries(skills).map(([skill, settings], index) => (
+
handleClick?.(skill)}
+ >
+
{settings.title}
+
+ {isDefault ? (
+
+ ) : (
+
+ {activeSkills.includes(skill) ? "On" : "Off"}
+
+ )}
+
+
+
+ ))}
+
+ {/* Tooltip for default skills - only render when skill list is passed isDefault */}
+ {isDefault && (
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/Agents/skills.js b/frontend/src/pages/Admin/Agents/skills.js
new file mode 100644
index 0000000000000000000000000000000000000000..ff6f4f3c3cfd18e1ec9314e3230de71f32705548
--- /dev/null
+++ b/frontend/src/pages/Admin/Agents/skills.js
@@ -0,0 +1,76 @@
+import AgentWebSearchSelection from "./WebSearchSelection";
+import AgentSQLConnectorSelection from "./SQLConnectorSelection";
+import GenericSkillPanel from "./GenericSkillPanel";
+import DefaultSkillPanel from "./DefaultSkillPanel";
+import {
+ Brain,
+ File,
+ Browser,
+ ChartBar,
+ FileMagnifyingGlass,
+} from "@phosphor-icons/react";
+import RAGImage from "@/media/agents/rag-memory.png";
+import SummarizeImage from "@/media/agents/view-summarize.png";
+import ScrapeWebsitesImage from "@/media/agents/scrape-websites.png";
+import GenerateChartsImage from "@/media/agents/generate-charts.png";
+import GenerateSaveImages from "@/media/agents/generate-save-files.png";
+
+export const defaultSkills = {
+ "rag-memory": {
+ title: "RAG & long-term memory",
+ description:
+ 'Allow the agent to leverage your local documents to answer a query or ask the agent to "remember" pieces of content for long-term memory retrieval.',
+ component: DefaultSkillPanel,
+ icon: Brain,
+ image: RAGImage,
+ skill: "rag-memory",
+ },
+ "document-summarizer": {
+ title: "View & summarize documents",
+ description:
+ "Allow the agent to list and summarize the content of workspace files currently embedded.",
+ component: DefaultSkillPanel,
+ icon: File,
+ image: SummarizeImage,
+ skill: "document-summarizer",
+ },
+ "web-scraping": {
+ title: "Scrape websites",
+ description: "Allow the agent to visit and scrape the content of websites.",
+ component: DefaultSkillPanel,
+ icon: Browser,
+ image: ScrapeWebsitesImage,
+ skill: "web-scraping",
+ },
+};
+
+export const configurableSkills = {
+ "save-file-to-browser": {
+ title: "Generate & save files",
+ description:
+ "Enable the default agent to generate and write to files that can be saved to your computer.",
+ component: GenericSkillPanel,
+ skill: "save-file-to-browser",
+ icon: FileMagnifyingGlass,
+ image: GenerateSaveImages,
+ },
+ "create-chart": {
+ title: "Generate charts",
+ description:
+ "Enable the default agent to generate various types of charts from data provided or given in chat.",
+ component: GenericSkillPanel,
+ skill: "create-chart",
+ icon: ChartBar,
+ image: GenerateChartsImage,
+ },
+ "web-browsing": {
+ title: "Web Search",
+ component: AgentWebSearchSelection,
+ skill: "web-browsing",
+ },
+ "sql-agent": {
+ title: "SQL Connector",
+ component: AgentSQLConnectorSelection,
+ skill: "sql-agent",
+ },
+};
diff --git a/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/manage/DocumentSyncQueueRow/index.jsx b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/manage/DocumentSyncQueueRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bf6991596eebdc01efc4d138b9fc6785dc200ddd
--- /dev/null
+++ b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/manage/DocumentSyncQueueRow/index.jsx
@@ -0,0 +1,44 @@
+import { useRef } from "react";
+import { Trash } from "@phosphor-icons/react";
+import { stripUuidAndJsonFromString } from "@/components/Modals/ManageWorkspace/Documents/Directory/utils";
+import moment from "moment";
+import System from "@/models/system";
+
+export default function DocumentSyncQueueRow({ queue }) {
+ const rowRef = useRef(null);
+ const handleDelete = async () => {
+ rowRef?.current?.remove();
+ await System.experimentalFeatures.liveSync.setWatchStatusForDocument(
+ queue.workspaceDoc.workspace.slug,
+ queue.workspaceDoc.docpath,
+ false
+ );
+ };
+
+ return (
+ <>
+
+
+ {stripUuidAndJsonFromString(queue.workspaceDoc.filename)}
+
+ {moment(queue.lastSyncedAt).fromNow()}
+
+ {moment(queue.nextSyncAt).format("lll")}
+ ({moment(queue.nextSyncAt).fromNow()})
+
+ {moment(queue.createdAt).format("lll")}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/manage/index.jsx b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/manage/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a60709fa16b34073a842d8e03de672b7172ae69c
--- /dev/null
+++ b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/manage/index.jsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/Sidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import System from "@/models/system";
+import DocumentSyncQueueRow from "./DocumentSyncQueueRow";
+
+export default function LiveDocumentSyncManager() {
+ return (
+
+
+
+
+
+
+
+ Watched documents
+
+
+
+ These are all the documents that are currently being watched in
+ your instance. The content of these documents will be periodically
+ synced.
+
+
+
+
+
+
+
+
+ );
+}
+
+function WatchedDocumentsContainer() {
+ const [loading, setLoading] = useState(true);
+ const [queues, setQueues] = useState([]);
+
+ useEffect(() => {
+ async function fetchData() {
+ const _queues = await System.experimentalFeatures.liveSync.queues();
+ setQueues(_queues);
+ setLoading(false);
+ }
+ fetchData();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Document Name
+
+
+ Last Synced
+
+
+ Time until next refresh
+
+
+ Created On
+
+
+ {" "}
+
+
+
+
+ {queues.map((queue) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..91024635c0fc23416db94ca81ca90f3dc08a0d97
--- /dev/null
+++ b/frontend/src/pages/Admin/ExperimentalFeatures/Features/LiveSync/toggle.jsx
@@ -0,0 +1,89 @@
+import System from "@/models/system";
+import paths from "@/utils/paths";
+import showToast from "@/utils/toast";
+import { ArrowSquareOut } from "@phosphor-icons/react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+
+export default function LiveSyncToggle({ enabled = false, onToggle }) {
+ const [status, setStatus] = useState(enabled);
+
+ async function toggleFeatureFlag() {
+ const updated =
+ await System.experimentalFeatures.liveSync.toggleFeature(!status);
+ if (!updated) {
+ showToast("Failed to update status of feature.", "error", {
+ clear: true,
+ });
+ return false;
+ }
+
+ setStatus(!status);
+ showToast(
+ `Live document content sync has been ${
+ !status ? "enabled" : "disabled"
+ }.`,
+ "success",
+ { clear: true }
+ );
+ onToggle();
+ }
+
+ return (
+
+
+
+
+ Automatic Document Content Sync
+
+
+
+
+
+
+
+
+ Enable the ability to specify a document to be "watched". Watched
+ document's content will be regularly fetched and updated in
+ AnythingLLM.
+
+
+ Watched documents will automatically update in all workspaces they
+ are referenced in at the same time of update.
+
+
+ This feature only applies to web-based content, such as websites,
+ Confluence, YouTube, and GitHub files.
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/ExperimentalFeatures/features.js b/frontend/src/pages/Admin/ExperimentalFeatures/features.js
new file mode 100644
index 0000000000000000000000000000000000000000..43d4626023d55a10e3b60dd8735d95658711d49c
--- /dev/null
+++ b/frontend/src/pages/Admin/ExperimentalFeatures/features.js
@@ -0,0 +1,16 @@
+import LiveSyncToggle from "./Features/LiveSync/toggle";
+import paths from "@/utils/paths";
+
+export const configurableFeatures = {
+ experimental_live_file_sync: {
+ title: "Live Document Sync",
+ component: LiveSyncToggle,
+ key: "experimental_live_file_sync",
+ },
+ experimental_mobile_connections: {
+ title: "AnythingLLM Mobile",
+ href: paths.settings.mobileConnections(),
+ key: "experimental_mobile_connections",
+ autoEnabled: true,
+ },
+};
diff --git a/frontend/src/pages/Admin/ExperimentalFeatures/index.jsx b/frontend/src/pages/Admin/ExperimentalFeatures/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ee2c7b2667ed88fb0d5d5b12ec4d90151ad55fda
--- /dev/null
+++ b/frontend/src/pages/Admin/ExperimentalFeatures/index.jsx
@@ -0,0 +1,300 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import Admin from "@/models/admin";
+import { FullScreenLoader } from "@/components/Preloader";
+import { CaretRight, Flask } from "@phosphor-icons/react";
+import { configurableFeatures } from "./features";
+import ModalWrapper from "@/components/ModalWrapper";
+import paths from "@/utils/paths";
+import showToast from "@/utils/toast";
+
+export default function ExperimentalFeatures() {
+ const [featureFlags, setFeatureFlags] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [selectedFeature, setSelectedFeature] = useState(
+ "experimental_live_file_sync"
+ );
+
+ useEffect(() => {
+ async function fetchSettings() {
+ setLoading(true);
+ const { settings } = await Admin.systemPreferences();
+ setFeatureFlags(settings?.feature_flags ?? {});
+ setLoading(false);
+ }
+ fetchSettings();
+ }, []);
+
+ const refresh = async () => {
+ const { settings } = await Admin.systemPreferences();
+ setFeatureFlags(settings?.feature_flags ?? {});
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Feature settings nav */}
+
+
+
+
Experimental Features
+
+ {/* Feature list */}
+
featureFlags[flag]
+ )}
+ />
+
+
+ {/* Selected feature setting panel */}
+
+
+
+ {selectedFeature ? (
+
+ ) : (
+
+
+
Select an experimental feature
+
+ )}
+
+
+
+
+
+ );
+}
+
+function FeatureLayout({ children }) {
+ return (
+
+ );
+}
+
+function FeatureList({
+ features = [],
+ selectedFeature = null,
+ handleClick = null,
+ activeFeatures = [],
+}) {
+ if (Object.keys(features).length === 0) return null;
+
+ return (
+
+ {Object.entries(features).map(([feature, settings], index) => (
+
{
+ if (settings?.href) window.location.replace(settings.href);
+ else handleClick?.(feature);
+ }}
+ >
+
{settings.title}
+
+ {settings.autoEnabled ? (
+ <>
+
+ On
+
+
+ >
+ ) : (
+ <>
+
+ {activeFeatures.includes(settings.key) ? "On" : "Off"}
+
+
+ >
+ )}
+
+
+ ))}
+
+ );
+}
+
+function SelectedFeatureComponent({ feature, settings, refresh }) {
+ const Component = feature?.component;
+ return Component ? (
+
+ ) : null;
+}
+
+function FeatureVerification({ children }) {
+ if (
+ !window.localStorage.getItem("anythingllm_tos_experimental_feature_set")
+ ) {
+ function acceptTos(e) {
+ e.preventDefault();
+
+ window.localStorage.setItem(
+ "anythingllm_tos_experimental_feature_set",
+ "accepted"
+ );
+ showToast(
+ "Experimental Feature set enabled. Reloading the page.",
+ "success"
+ );
+ setTimeout(() => {
+ window.location.reload();
+ }, 2_500);
+ return;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ Terms of use for experimental features
+
+
+
+
+
+
+ {children}
+ >
+ );
+ }
+ return <>{children}>;
+}
diff --git a/frontend/src/pages/Admin/Invitations/InviteRow/index.jsx b/frontend/src/pages/Admin/Invitations/InviteRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..4a73d2dd5e64f21d7f3c4868b59ce337cba5b23c
--- /dev/null
+++ b/frontend/src/pages/Admin/Invitations/InviteRow/index.jsx
@@ -0,0 +1,79 @@
+import { useEffect, useRef, useState } from "react";
+import { titleCase } from "text-case";
+import Admin from "@/models/admin";
+import { Trash } from "@phosphor-icons/react";
+
+export default function InviteRow({ invite }) {
+ const rowRef = useRef(null);
+ const [status, setStatus] = useState(invite.status);
+ const [copied, setCopied] = useState(false);
+ const handleDelete = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to deactivate this invite?\nAfter you do this it will not longer be useable.\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+ if (rowRef?.current) {
+ rowRef.current.children[0].innerText = "Disabled";
+ }
+ setStatus("disabled");
+ await Admin.disableInvite(invite.id);
+ };
+ const copyInviteLink = () => {
+ if (!invite) return false;
+ window.navigator.clipboard.writeText(
+ `${window.location.origin}/accept-invite/${invite.code}`
+ );
+ setCopied(true);
+ };
+
+ useEffect(() => {
+ function resetStatus() {
+ if (!copied) return false;
+ setTimeout(() => {
+ setCopied(false);
+ }, 3000);
+ }
+ resetStatus();
+ }, [copied]);
+
+ return (
+ <>
+
+
+ {titleCase(status)}
+
+
+ {invite.claimedBy
+ ? invite.claimedBy?.username || "deleted user"
+ : "--"}
+
+ {invite.createdBy?.username || "deleted user"}
+ {invite.createdAt}
+
+ {status === "pending" && (
+ <>
+
+ {copied ? "Copied" : "Copy Invite Link"}
+
+
+
+
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx b/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8340ef083c6bb146172b1867932ab45afacb095a
--- /dev/null
+++ b/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx
@@ -0,0 +1,217 @@
+import React, { useEffect, useState } from "react";
+import { X, Copy, Check } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+
+export default function NewInviteModal({ closeModal, onSuccess }) {
+ const [invite, setInvite] = useState(null);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [workspaces, setWorkspaces] = useState([]);
+ const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState([]);
+
+ const handleCreate = async (e) => {
+ setError(null);
+ e.preventDefault();
+
+ const { invite: newInvite, error } = await Admin.newInvite({
+ role: null,
+ workspaceIds: selectedWorkspaceIds,
+ });
+ if (!!newInvite) {
+ setInvite(newInvite);
+ onSuccess();
+ }
+ setError(error);
+ };
+
+ const copyInviteLink = () => {
+ if (!invite) return false;
+ window.navigator.clipboard.writeText(
+ `${window.location.origin}/accept-invite/${invite.code}`
+ );
+ setCopied(true);
+ showToast("Invite link copied to clipboard", "success", {
+ clear: true,
+ });
+ };
+
+ const handleWorkspaceSelection = (workspaceId) => {
+ if (selectedWorkspaceIds.includes(workspaceId)) {
+ const updated = selectedWorkspaceIds.filter((id) => id !== workspaceId);
+ setSelectedWorkspaceIds(updated);
+ return;
+ }
+ setSelectedWorkspaceIds([...selectedWorkspaceIds, workspaceId]);
+ };
+
+ useEffect(() => {
+ function resetStatus() {
+ if (!copied) return false;
+ setTimeout(() => {
+ setCopied(false);
+ }, 3000);
+ }
+ resetStatus();
+ }, [copied]);
+
+ useEffect(() => {
+ async function fetchWorkspaces() {
+ Workspace.all()
+ .then((workspaces) => setWorkspaces(workspaces))
+ .catch(() => setWorkspaces([]));
+ }
+ fetchWorkspaces();
+ }, []);
+
+ return (
+
+
+
+
+
+ Create new invite
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function WorkspaceOption({ workspace, selected, toggleSelection }) {
+ return (
+
toggleSelection(workspace.id)}
+ className={`transition-all duration-300 w-full h-11 p-2.5 rounded-lg flex justify-start items-center gap-2.5 cursor-pointer border ${
+ selected
+ ? "border-theme-sidebar-item-workspace-active bg-theme-bg-secondary"
+ : "border-theme-sidebar-border"
+ } hover:border-theme-sidebar-border hover:bg-theme-bg-secondary`}
+ >
+
+
+
+ {workspace.name}
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Invitations/index.jsx b/frontend/src/pages/Admin/Invitations/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d7f9d5ed28a8f4819771252f6129ae66cc9218b1
--- /dev/null
+++ b/frontend/src/pages/Admin/Invitations/index.jsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { EnvelopeSimple } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import InviteRow from "./InviteRow";
+import NewInviteModal from "./NewInviteModal";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function AdminInvites() {
+ const { isOpen, openModal, closeModal } = useModal();
+ const [loading, setLoading] = useState(true);
+ const [invites, setInvites] = useState([]);
+
+ const fetchInvites = async () => {
+ const _invites = await Admin.invites();
+ setInvites(_invites);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ fetchInvites();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ Create invitation links for people in your organization to accept
+ and sign up with. Invitations can only be used by a single user.
+
+
+
+
+ Create Invite
+ Link
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ Status
+
+
+ Accepted By
+
+
+ Created By
+
+
+ Created
+
+
+ {" "}
+
+
+
+
+ {invites.length === 0 ? (
+
+
+ No invitations found
+
+
+ ) : (
+ invites.map((invite) => (
+
+ ))
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Logging/LogRow/index.jsx b/frontend/src/pages/Admin/Logging/LogRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cd5dc85f87ac837c7f6cf983a7142feaf3f0dff8
--- /dev/null
+++ b/frontend/src/pages/Admin/Logging/LogRow/index.jsx
@@ -0,0 +1,117 @@
+import { CaretDown, CaretUp } from "@phosphor-icons/react";
+import { useEffect, useState } from "react";
+
+export default function LogRow({ log }) {
+ const [expanded, setExpanded] = useState(false);
+ const [metadata, setMetadata] = useState(null);
+ const [hasMetadata, setHasMetadata] = useState(false);
+
+ useEffect(() => {
+ function parseAndSetMetadata() {
+ try {
+ let data = JSON.parse(log.metadata);
+ setHasMetadata(Object.keys(data)?.length > 0);
+ setMetadata(data);
+ } catch {}
+ }
+ parseAndSetMetadata();
+ }, [log.metadata]);
+
+ const handleRowClick = () => {
+ if (log.metadata !== "{}") {
+ setExpanded(!expanded);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {log.user.username}
+
+
+ {log.occurredAt}
+
+ {hasMetadata && (
+
+ {expanded ? (
+
+
+ hide
+
+ ) : (
+
+
+ show
+
+ )}
+
+ )}
+
+
+ >
+ );
+}
+
+const EventMetadata = ({ metadata, expanded = false }) => {
+ if (!metadata || !expanded) return null;
+ return (
+
+
+ Event Metadata
+
+
+
+
+ {JSON.stringify(metadata, null, 2)}
+
+
+
+
+ );
+};
+
+const EventBadge = ({ event }) => {
+ let colorTheme = {
+ bg: "bg-sky-600/20",
+ text: "text-sky-400 light:text-sky-800",
+ };
+ if (event.includes("update"))
+ colorTheme = {
+ bg: "bg-yellow-600/20",
+ text: "text-yellow-400 light:text-yellow-800",
+ };
+ if (event.includes("failed_") || event.includes("deleted"))
+ colorTheme = {
+ bg: "bg-red-600/20",
+ text: "text-red-400 light:text-red-800",
+ };
+ if (event === "login_event")
+ colorTheme = {
+ bg: "bg-green-600/20",
+ text: "text-green-400 light:text-green-800",
+ };
+
+ return (
+
+
+ {event}
+
+
+ );
+};
diff --git a/frontend/src/pages/Admin/Logging/index.jsx b/frontend/src/pages/Admin/Logging/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..2a482f51222c1c7a7c6f9ec71355e747426e7f90
--- /dev/null
+++ b/frontend/src/pages/Admin/Logging/index.jsx
@@ -0,0 +1,162 @@
+import Sidebar from "@/components/SettingsSidebar";
+import useQuery from "@/hooks/useQuery";
+import System from "@/models/system";
+import { useEffect, useState } from "react";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import LogRow from "./LogRow";
+import showToast from "@/utils/toast";
+import CTAButton from "@/components/lib/CTAButton";
+import { useTranslation } from "react-i18next";
+
+export default function AdminLogs() {
+ const query = useQuery();
+ const [loading, setLoading] = useState(true);
+ const [logs, setLogs] = useState([]);
+ const [offset, setOffset] = useState(Number(query.get("offset") || 0));
+ const [canNext, setCanNext] = useState(false);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ async function fetchLogs() {
+ const { logs: _logs, hasPages = false } = await System.eventLogs(offset);
+ setLogs(_logs);
+ setCanNext(hasPages);
+ setLoading(false);
+ }
+ fetchLogs();
+ }, [offset]);
+
+ const handleResetLogs = async () => {
+ if (
+ !window.confirm(
+ "Are you sure you want to clear all event logs? This action is irreversible."
+ )
+ )
+ return;
+ const { success, error } = await System.clearEventLogs();
+ if (success) {
+ showToast("Event logs cleared successfully.", "success");
+ setLogs([]);
+ setCanNext(false);
+ setOffset(0);
+ } else {
+ showToast(`Failed to clear logs: ${error}`, "error");
+ }
+ };
+
+ const handlePrevious = () => {
+ setOffset(Math.max(offset - 1, 0));
+ };
+
+ const handleNext = () => {
+ setOffset(offset + 1);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {t("event.title")}
+
+
+
+ {t("event.description")}
+
+
+
+
+ {t("event.clear")}
+
+
+
+
+
+
+
+
+ );
+}
+
+function LogsContainer({
+ loading,
+ logs,
+ offset,
+ canNext,
+ handleNext,
+ handlePrevious,
+}) {
+ const { t } = useTranslation();
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {t("event.table.type")}
+
+
+ {t("event.table.user")}
+
+
+ {t("event.table.occurred")}
+
+
+ {" "}
+
+
+
+
+ {!!logs && logs.map((log) => )}
+
+
+
+
+ {t("common.previous")}
+
+
+ {t("common.next")}
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/SystemPromptVariables/AddVariableModal/index.jsx b/frontend/src/pages/Admin/SystemPromptVariables/AddVariableModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3828b220ba39e10b8a6cf8761772373f4cb0eafb
--- /dev/null
+++ b/frontend/src/pages/Admin/SystemPromptVariables/AddVariableModal/index.jsx
@@ -0,0 +1,129 @@
+import React, { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+
+export default function AddVariableModal({ closeModal, onRefresh }) {
+ const [error, setError] = useState(null);
+
+ const handleCreate = async (e) => {
+ e.preventDefault();
+ setError(null);
+ const formData = new FormData(e.target);
+ const newVariable = {};
+ for (const [key, value] of formData.entries())
+ newVariable[key] = value.trim();
+
+ if (!newVariable.key || !newVariable.value) {
+ setError("Key and value are required");
+ return;
+ }
+
+ try {
+ await System.promptVariables.create(newVariable);
+ showToast("Variable created successfully", "success", { clear: true });
+ if (onRefresh) onRefresh();
+ closeModal();
+ } catch (error) {
+ console.error("Error creating variable:", error);
+ setError("Failed to create variable");
+ }
+ };
+
+ return (
+
+
+
+
+
+ Add New Variable
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/EditVariableModal/index.jsx b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/EditVariableModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..6d813307eabcc92178a8f96b247532b6905af24a
--- /dev/null
+++ b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/EditVariableModal/index.jsx
@@ -0,0 +1,133 @@
+import React, { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+
+export default function EditVariableModal({ variable, closeModal, onRefresh }) {
+ const [error, setError] = useState(null);
+
+ const handleUpdate = async (e) => {
+ if (!variable.id) return;
+ e.preventDefault();
+ setError(null);
+ const formData = new FormData(e.target);
+ const updatedVariable = {};
+ for (const [key, value] of formData.entries())
+ updatedVariable[key] = value.trim();
+
+ if (!updatedVariable.key || !updatedVariable.value) {
+ setError("Key and value are required");
+ return;
+ }
+
+ try {
+ await System.promptVariables.update(variable.id, updatedVariable);
+ showToast("Variable updated successfully", "success", { clear: true });
+ if (onRefresh) onRefresh();
+ closeModal();
+ } catch (error) {
+ console.error("Error updating variable:", error);
+ setError("Failed to update variable");
+ }
+ };
+
+ return (
+
+
+
+
+
+ Edit {variable.key}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ac4c3ea812057235ae1cf9b166f511609805ad45
--- /dev/null
+++ b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx
@@ -0,0 +1,115 @@
+import { useRef } from "react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import EditVariableModal from "./EditVariableModal";
+import { titleCase } from "text-case";
+import truncate from "truncate";
+import { Trash } from "@phosphor-icons/react";
+
+/**
+ * A row component for displaying a system prompt variable
+ * @param {{id: number|null, key: string, value: string, description: string, type: string}} variable - The system prompt variable to display
+ * @param {Function} onRefresh - A function to call when the variable is refreshed
+ * @returns {JSX.Element} A JSX element for displaying the variable
+ */
+export default function VariableRow({ variable, onRefresh }) {
+ const rowRef = useRef(null);
+ const { isOpen, openModal, closeModal } = useModal();
+
+ const handleDelete = async () => {
+ if (!variable.id) return;
+ if (
+ !window.confirm(
+ `Are you sure you want to delete the variable "${variable.key}"?\nThis action is irreversible.`
+ )
+ )
+ return false;
+
+ try {
+ await System.promptVariables.delete(variable.id);
+ rowRef?.current?.remove();
+ showToast("Variable deleted successfully", "success", { clear: true });
+ if (onRefresh) onRefresh();
+ } catch (error) {
+ console.error("Error deleting variable:", error);
+ showToast("Failed to delete variable", "error", { clear: true });
+ }
+ };
+
+ const getTypeColorTheme = (type) => {
+ switch (type) {
+ case "system":
+ return {
+ bg: "bg-blue-600/20",
+ text: "text-blue-400 light:text-blue-800",
+ };
+ case "dynamic":
+ return {
+ bg: "bg-green-600/20",
+ text: "text-green-400 light:text-green-800",
+ };
+ default:
+ return {
+ bg: "bg-yellow-600/20",
+ text: "text-yellow-400 light:text-yellow-800",
+ };
+ }
+ };
+
+ const colorTheme = getTypeColorTheme(variable.type);
+
+ return (
+ <>
+
+
+ {variable.key}
+
+
+ {typeof variable.value === "function"
+ ? variable.value()
+ : truncate(variable.value, 50)}
+
+
+ {truncate(variable.description || "-", 50)}
+
+
+
+ {titleCase(variable.type)}
+
+
+
+ {variable.type === "static" && (
+ <>
+
+ Edit
+
+
+
+
+ >
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/SystemPromptVariables/index.jsx b/frontend/src/pages/Admin/SystemPromptVariables/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..21a84c4bab44d90d7598ee90d8476d77ff3f0460
--- /dev/null
+++ b/frontend/src/pages/Admin/SystemPromptVariables/index.jsx
@@ -0,0 +1,120 @@
+import React, { useState, useEffect } from "react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { Plus } from "@phosphor-icons/react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import CTAButton from "@/components/lib/CTAButton";
+import VariableRow from "./VariableRow";
+import ModalWrapper from "@/components/ModalWrapper";
+import AddVariableModal from "./AddVariableModal";
+import { useModal } from "@/hooks/useModal";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+
+export default function SystemPromptVariables() {
+ const [variables, setVariables] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const { isOpen, openModal, closeModal } = useModal();
+
+ useEffect(() => {
+ fetchVariables();
+ }, []);
+
+ const fetchVariables = async () => {
+ setLoading(true);
+ try {
+ const { variables } = await System.promptVariables.getAll();
+ setVariables(variables || []);
+ } catch (error) {
+ console.error("Error fetching variables:", error);
+ showToast("No variables found", "error");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ System Prompt Variables
+
+
+
+ System prompt variables are used to store configuration values
+ that can be referenced in your system prompt to enable dynamic
+ content in your prompts.
+
+
+
+
+
+
+ {loading ? (
+
+ ) : variables.length === 0 ? (
+
+ No variables found
+
+ ) : (
+
+
+
+
+ Key
+
+
+ Value
+
+
+ Description
+
+
+ Type
+
+
+
+
+ {variables.map((variable) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Users/NewUserModal/index.jsx b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..236b8c78119ea2df24c9371b09ffc9462270c87b
--- /dev/null
+++ b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
@@ -0,0 +1,160 @@
+import React, { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import { userFromStorage } from "@/utils/request";
+import { MessageLimitInput, RoleHintDisplay } from "..";
+
+export default function NewUserModal({ closeModal }) {
+ const [error, setError] = useState(null);
+ const [role, setRole] = useState("default");
+ const [messageLimit, setMessageLimit] = useState({
+ enabled: false,
+ limit: 10,
+ });
+
+ const handleCreate = async (e) => {
+ setError(null);
+ e.preventDefault();
+ const data = {};
+ const form = new FormData(e.target);
+ for (var [key, value] of form.entries()) data[key] = value;
+ data.dailyMessageLimit = messageLimit.enabled ? messageLimit.limit : null;
+
+ const { user, error } = await Admin.newUser(data);
+ if (!!user) window.location.reload();
+ setError(error);
+ };
+
+ const user = userFromStorage();
+
+ return (
+
+
+
+
+
+ Add user to instance
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..aaf1b658be3452fd388f0b8d4e3c27706679817c
--- /dev/null
+++ b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
@@ -0,0 +1,172 @@
+import React, { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import { MessageLimitInput, RoleHintDisplay } from "../..";
+import { AUTH_USER } from "@/utils/constants";
+
+export default function EditUserModal({ currentUser, user, closeModal }) {
+ const [role, setRole] = useState(user.role);
+ const [error, setError] = useState(null);
+ const [messageLimit, setMessageLimit] = useState({
+ enabled: user.dailyMessageLimit !== null,
+ limit: user.dailyMessageLimit || 10,
+ });
+
+ const handleUpdate = async (e) => {
+ setError(null);
+ e.preventDefault();
+ const data = {};
+ const form = new FormData(e.target);
+ for (var [key, value] of form.entries()) {
+ if (!value || value === null) continue;
+ data[key] = value;
+ }
+ if (messageLimit.enabled) {
+ data.dailyMessageLimit = messageLimit.limit;
+ } else {
+ data.dailyMessageLimit = null;
+ }
+
+ const { success, error } = await Admin.updateUser(user.id, data);
+ if (success) {
+ // Update local storage if we're editing our own user
+ if (currentUser && currentUser.id === user.id) {
+ currentUser.username = data.username;
+ currentUser.bio = data.bio;
+ currentUser.role = data.role;
+ localStorage.setItem(AUTH_USER, JSON.stringify(currentUser));
+ }
+
+ window.location.reload();
+ }
+ setError(error);
+ };
+
+ return (
+
+
+
+
+
+ Edit {user.username}
+
+
+
+
+
+
+
+
+
+
+
+ Username
+
+
+
+ Username must only contain lowercase letters, periods,
+ numbers, underscores, and hyphens with no spaces
+
+
+
+
+ New Password
+
+
+
+ Password must be at least 8 characters long
+
+
+
+
+ Bio
+
+
+
+
+
+ Role
+
+ setRole(e.target.value)}
+ className="border-none bg-theme-settings-input-bg w-full text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ >
+ Default
+ Manager
+ {currentUser?.role === "admin" && (
+ Administrator
+ )}
+
+
+
+
+ {error &&
Error: {error}
}
+
+
+
+ Cancel
+
+
+ Update user
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Users/UserRow/index.jsx b/frontend/src/pages/Admin/Users/UserRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3198895e091a7d9c74c89153c12752c0f463b082
--- /dev/null
+++ b/frontend/src/pages/Admin/Users/UserRow/index.jsx
@@ -0,0 +1,103 @@
+import { useRef, useState } from "react";
+import { titleCase } from "text-case";
+import Admin from "@/models/admin";
+import EditUserModal from "./EditUserModal";
+import showToast from "@/utils/toast";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+
+const ModMap = {
+ admin: ["admin", "manager", "default"],
+ manager: ["manager", "default"],
+ default: [],
+};
+
+export default function UserRow({ currUser, user }) {
+ const rowRef = useRef(null);
+ const canModify = ModMap[currUser?.role || "default"].includes(user.role);
+ const [suspended, setSuspended] = useState(user.suspended === 1);
+ const { isOpen, openModal, closeModal } = useModal();
+ const handleSuspend = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to suspend ${user.username}?\nAfter you do this they will be logged out and unable to log back into this instance of AnythingLLM until unsuspended by an admin.`
+ )
+ )
+ return false;
+
+ const { success, error } = await Admin.updateUser(user.id, {
+ suspended: suspended ? 0 : 1,
+ });
+ if (!success) showToast(error, "error", { clear: true });
+ if (success) {
+ showToast(
+ `User ${!suspended ? "has been suspended" : "is no longer suspended"}.`,
+ "success",
+ { clear: true }
+ );
+ setSuspended(!suspended);
+ }
+ };
+ const handleDelete = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to delete ${user.username}?\nAfter you do this they will be logged out and unable to use this instance of AnythingLLM.\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+ const { success, error } = await Admin.deleteUser(user.id);
+ if (!success) showToast(error, "error", { clear: true });
+ if (success) {
+ rowRef?.current?.remove();
+ showToast("User deleted from system.", "success", { clear: true });
+ }
+ };
+
+ return (
+ <>
+
+
+ {user.username}
+
+ {titleCase(user.role)}
+ {user.createdAt}
+
+ {canModify && (
+
+ Edit
+
+ )}
+ {currUser?.id !== user.id && canModify && (
+ <>
+
+ {suspended ? "Unsuspend" : "Suspend"}
+
+
+ Delete
+
+ >
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/Users/index.jsx b/frontend/src/pages/Admin/Users/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..37598947647dcaa36b31c2b90a3b03f8e83b1bce
--- /dev/null
+++ b/frontend/src/pages/Admin/Users/index.jsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { UserPlus } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import UserRow from "./UserRow";
+import useUser from "@/hooks/useUser";
+import NewUserModal from "./NewUserModal";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function AdminUsers() {
+ const { isOpen, openModal, closeModal } = useModal();
+
+ return (
+
+
+
+
+
+
+
+ These are all the accounts which have an account on this instance.
+ Removing an account will instantly remove their access to this
+ instance.
+
+
+
+
+ Add user
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function UsersContainer() {
+ const { user: currUser } = useUser();
+ const [loading, setLoading] = useState(true);
+ const [users, setUsers] = useState([]);
+
+ useEffect(() => {
+ async function fetchUsers() {
+ const _users = await Admin.users();
+ setUsers(_users);
+ setLoading(false);
+ }
+ fetchUsers();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Username
+
+
+ Role
+
+
+ Date Added
+
+
+ {" "}
+
+
+
+
+ {users.map((user) => (
+
+ ))}
+
+
+ );
+}
+
+const ROLE_HINT = {
+ default: [
+ "Can only send chats with workspaces they are added to by admin or managers.",
+ "Cannot modify any settings at all.",
+ ],
+ manager: [
+ "Can view, create, and delete any workspaces and modify workspace-specific settings.",
+ "Can create, update and invite new users to the instance.",
+ "Cannot modify LLM, vectorDB, embedding, or other connections.",
+ ],
+ admin: [
+ "Highest user level privilege.",
+ "Can see and do everything across the system.",
+ ],
+};
+
+export function RoleHintDisplay({ role }) {
+ return (
+
+
Permissions
+
+ {ROLE_HINT[role ?? "default"].map((hints, i) => {
+ return (
+
+ {hints}
+
+ );
+ })}
+
+
+ );
+}
+
+export function MessageLimitInput({ enabled, limit, updateState, role }) {
+ if (role === "admin") return null;
+ return (
+
+
+
+
+ Limit messages per day
+
+
+ {
+ updateState((prev) => ({
+ ...prev,
+ enabled: e.target.checked,
+ }));
+ }}
+ className="peer sr-only"
+ />
+
+
+
+
+ Restrict this user to a number of successful queries or chats within a
+ 24 hour window.
+
+
+ {enabled && (
+
+
+ Message limit per day
+
+
+ e.target.blur()}
+ onChange={(e) => {
+ updateState({
+ enabled: true,
+ limit: Number(e?.target?.value || 0),
+ });
+ }}
+ value={limit}
+ min={1}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ />
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx b/frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..34cf9fd5e30f9e99b2c91da8cbb6f33809749aa7
--- /dev/null
+++ b/frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx
@@ -0,0 +1,81 @@
+import React, { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import { useTranslation } from "react-i18next";
+
+export default function NewWorkspaceModal({ closeModal }) {
+ const [error, setError] = useState(null);
+ const { t } = useTranslation();
+ const handleCreate = async (e) => {
+ setError(null);
+ e.preventDefault();
+ const form = new FormData(e.target);
+ const { workspace, error } = await Admin.newWorkspace(form.get("name"));
+ if (!!workspace) window.location.reload();
+ setError(error);
+ };
+
+ return (
+
+
+
+
+
+ Create new workspace
+
+
+
+
+
+
+
+
+
+
+
+ {t("common.workspaces-name")}
+
+
+
+ {error &&
Error: {error}
}
+
+ After creating this workspace only admins will be able to see
+ it. You can add users after it has been created.
+
+
+
+
+ Cancel
+
+
+ Create workspace
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8a774e0badaffabb237004ccd1ae55bb3133c09e
--- /dev/null
+++ b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx
@@ -0,0 +1,58 @@
+import { useRef } from "react";
+import Admin from "@/models/admin";
+import paths from "@/utils/paths";
+import { LinkSimple, Trash } from "@phosphor-icons/react";
+
+export default function WorkspaceRow({ workspace, users }) {
+ const rowRef = useRef(null);
+ const handleDelete = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to delete ${workspace.name}?\nAfter you do this it will be unavailable in this instance of AnythingLLM.\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+ rowRef?.current?.remove();
+ await Admin.deleteWorkspace(workspace.id);
+ };
+
+ return (
+ <>
+
+
+ {workspace.name}
+
+
+
+ {workspace.slug}
+
+
+
+
+ {workspace.userIds?.length}
+
+
+ {workspace.createdAt}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/Workspaces/index.jsx b/frontend/src/pages/Admin/Workspaces/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8cdb57659372e7de523ac9898f8784ff14417749
--- /dev/null
+++ b/frontend/src/pages/Admin/Workspaces/index.jsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { BookOpen } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import WorkspaceRow from "./WorkspaceRow";
+import NewWorkspaceModal from "./NewWorkspaceModal";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function AdminWorkspaces() {
+ const { isOpen, openModal, closeModal } = useModal();
+
+ return (
+
+
+
+
+
+
+
+ Instance Workspaces
+
+
+
+ These are all the workspaces that exist on this instance. Removing
+ a workspace will delete all of its associated chats and settings.
+
+
+
+
+ New Workspace
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function WorkspacesContainer() {
+ const [loading, setLoading] = useState(true);
+ const [users, setUsers] = useState([]);
+ const [workspaces, setWorkspaces] = useState([]);
+
+ useEffect(() => {
+ async function fetchData() {
+ const _users = await Admin.users();
+ const _workspaces = await Admin.workspaces();
+ setUsers(_users);
+ setWorkspaces(_workspaces);
+ setLoading(false);
+ }
+ fetchData();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Name
+
+
+ Link
+
+
+ Users
+
+
+ Created On
+
+
+ {" "}
+
+
+
+
+ {workspaces.map((workspace) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ApiKeys/ApiKeyRow/index.jsx b/frontend/src/pages/GeneralSettings/ApiKeys/ApiKeyRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..786c89ff5b40203bfc691825b5f38675b8079f4c
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ApiKeys/ApiKeyRow/index.jsx
@@ -0,0 +1,68 @@
+import { useEffect, useState } from "react";
+import Admin from "@/models/admin";
+import showToast from "@/utils/toast";
+import { Trash } from "@phosphor-icons/react";
+import { userFromStorage } from "@/utils/request";
+import System from "@/models/system";
+
+export default function ApiKeyRow({ apiKey, removeApiKey }) {
+ const [copied, setCopied] = useState(false);
+ const handleDelete = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to deactivate this api key?\nAfter you do this it will not longer be useable.\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+
+ const user = userFromStorage();
+ const Model = !!user ? Admin : System;
+ await Model.deleteApiKey(apiKey.id);
+ showToast("API Key permanently deleted", "info");
+ removeApiKey(apiKey.id);
+ };
+
+ const copyApiKey = () => {
+ if (!apiKey) return false;
+ window.navigator.clipboard.writeText(apiKey.secret);
+ showToast("API Key copied to clipboard", "success");
+ setCopied(true);
+ };
+
+ useEffect(() => {
+ function resetStatus() {
+ if (!copied) return false;
+ setTimeout(() => {
+ setCopied(false);
+ }, 3000);
+ }
+ resetStatus();
+ }, [copied]);
+
+ return (
+ <>
+
+
+ {apiKey.secret}
+
+ {apiKey.createdBy?.username || "--"}
+ {apiKey.createdAt}
+
+
+ {copied ? "Copied" : "Copy API Key"}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ApiKeys/NewApiKeyModal/index.jsx b/frontend/src/pages/GeneralSettings/ApiKeys/NewApiKeyModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8e0248b5553b66bb84cc0f52601352f2df676d25
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ApiKeys/NewApiKeyModal/index.jsx
@@ -0,0 +1,139 @@
+import React, { useEffect, useState } from "react";
+import { X, Copy, Check } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import paths from "@/utils/paths";
+import { userFromStorage } from "@/utils/request";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+
+export default function NewApiKeyModal({ closeModal, onSuccess }) {
+ const [apiKey, setApiKey] = useState(null);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const handleCreate = async (e) => {
+ setError(null);
+ e.preventDefault();
+ const user = userFromStorage();
+ const Model = !!user ? Admin : System;
+
+ const { apiKey: newApiKey, error } = await Model.generateApiKey();
+ if (!!newApiKey) {
+ setApiKey(newApiKey);
+ onSuccess();
+ }
+ setError(error);
+ };
+
+ const copyApiKey = () => {
+ if (!apiKey) return false;
+ window.navigator.clipboard.writeText(apiKey.secret);
+ setCopied(true);
+ showToast("API key copied to clipboard", "success", {
+ clear: true,
+ });
+ };
+
+ useEffect(() => {
+ function resetStatus() {
+ if (!copied) return false;
+ setTimeout(() => {
+ setCopied(false);
+ }, 3000);
+ }
+ resetStatus();
+ }, [copied]);
+
+ return (
+
+
+
+
+
+ Create new API key
+
+
+
+
+
+
+
+
+
+ {error &&
Error: {error}
}
+ {apiKey && (
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ Once created the API key can be used to programmatically access
+ and configure this AnythingLLM instance.
+
+
+ Read the API documentation →
+
+
+
+ {!apiKey ? (
+ <>
+
+ Cancel
+
+
+ Create API Key
+
+ >
+ ) : (
+
+ Close
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ApiKeys/index.jsx b/frontend/src/pages/GeneralSettings/ApiKeys/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c2e3c2a61c104e4b404d8566b11e1d750467501
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ApiKeys/index.jsx
@@ -0,0 +1,134 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { PlusCircle } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import ApiKeyRow from "./ApiKeyRow";
+import NewApiKeyModal from "./NewApiKeyModal";
+import paths from "@/utils/paths";
+import { userFromStorage } from "@/utils/request";
+import System from "@/models/system";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import CTAButton from "@/components/lib/CTAButton";
+import { useTranslation } from "react-i18next";
+
+export default function AdminApiKeys() {
+ const { isOpen, openModal, closeModal } = useModal();
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(true);
+ const [apiKeys, setApiKeys] = useState([]);
+
+ const fetchExistingKeys = async () => {
+ const user = userFromStorage();
+ const Model = !!user ? Admin : System;
+ const { apiKeys: foundKeys } = await Model.getApiKeys();
+ setApiKeys(foundKeys);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ fetchExistingKeys();
+ }, []);
+
+ const removeApiKey = (id) => {
+ setApiKeys((prevKeys) => prevKeys.filter((apiKey) => apiKey.id !== id));
+ };
+
+ return (
+
+
+
+
+
+
+
+ {" "}
+ {t("api.generate")}
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ {t("api.table.key")}
+
+
+ {t("api.table.by")}
+
+
+ {t("api.table.created")}
+
+
+ {" "}
+
+
+
+
+ {apiKeys.length === 0 ? (
+
+
+ No API keys found
+
+
+ ) : (
+ apiKeys.map((apiKey) => (
+
+ ))
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/AudioPreference/index.jsx b/frontend/src/pages/GeneralSettings/AudioPreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f0a88941ba5fd0aec06804a29293b1f5808635bb
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/AudioPreference/index.jsx
@@ -0,0 +1,45 @@
+import React, { useEffect, useState, useRef } from "react";
+import { isMobile } from "react-device-detect";
+import Sidebar from "@/components/SettingsSidebar";
+import System from "@/models/system";
+import PreLoader from "@/components/Preloader";
+import SpeechToTextProvider from "./stt";
+import TextToSpeechProvider from "./tts";
+
+export default function AudioPreference() {
+ const [settings, setSettings] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ setLoading(false);
+ }
+ fetchKeys();
+ }, []);
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/AudioPreference/stt.jsx b/frontend/src/pages/GeneralSettings/AudioPreference/stt.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b812dc89672547727a2c18dc9593808cdb62a5a0
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/AudioPreference/stt.jsx
@@ -0,0 +1,191 @@
+import React, { useEffect, useState, useRef } from "react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import LLMItem from "@/components/LLMSelection/LLMItem";
+import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
+import CTAButton from "@/components/lib/CTAButton";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import BrowserNative from "@/components/SpeechToText/BrowserNative";
+
+const PROVIDERS = [
+ {
+ name: "System native",
+ value: "native",
+ logo: AnythingLLMIcon,
+ options: (settings) =>
,
+ description: "Uses your browser's built in STT service if supported.",
+ },
+];
+
+export default function SpeechToTextProvider({ settings }) {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredProviders, setFilteredProviders] = useState([]);
+ const [selectedProvider, setSelectedProvider] = useState(
+ settings?.SpeechToTextProvider || "native"
+ );
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const data = { SpeechToTextProvider: selectedProvider };
+ const formData = new FormData(form);
+
+ for (var [key, value] of formData.entries()) data[key] = value;
+ const { error } = await System.updateSystem(data);
+ setSaving(true);
+
+ if (error) {
+ showToast(`Failed to save preferences: ${error}`, "error");
+ } else {
+ showToast("Speech-to-text preferences saved successfully.", "success");
+ }
+ setSaving(false);
+ setHasChanges(!!error);
+ };
+
+ const updateProviderChoice = (selection) => {
+ setSearchQuery("");
+ setSelectedProvider(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ };
+
+ const handleXButton = () => {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ };
+
+ useEffect(() => {
+ const filtered = PROVIDERS.filter((provider) =>
+ provider.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredProviders(filtered);
+ }, [searchQuery, selectedProvider]);
+
+ const selectedProviderObject = PROVIDERS.find(
+ (provider) => provider.value === selectedProvider
+ );
+
+ return (
+
+
+
+
+
+ Speech-to-text Preference
+
+
+
+ Here you can specify what kind of text-to-speech and speech-to-text
+ providers you would want to use in your AnythingLLM experience. By
+ default, we use the browser's built in support for these services,
+ but you may want to use others.
+
+
+
+ {hasChanges && (
+ handleSubmit()}
+ className="mt-3 mr-0 -mb-14 z-10"
+ >
+ {saving ? "Saving..." : "Save changes"}
+
+ )}
+
+
Provider
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredProviders.map((provider) => (
+ updateProviderChoice(provider.value)}
+ />
+ ))}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedProviderObject.name}
+
+
+ {selectedProviderObject.description}
+
+
+
+
+
+ )}
+
+
setHasChanges(true)}
+ className="mt-4 flex flex-col gap-y-1"
+ >
+ {selectedProvider &&
+ PROVIDERS.find(
+ (provider) => provider.value === selectedProvider
+ )?.options(settings)}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx b/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..68f19a7bb24b2b69c9d38c5e922043e45309a7a4
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx
@@ -0,0 +1,226 @@
+import React, { useEffect, useState, useRef } from "react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import LLMItem from "@/components/LLMSelection/LLMItem";
+import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
+import CTAButton from "@/components/lib/CTAButton";
+import OpenAiLogo from "@/media/llmprovider/openai.png";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import ElevenLabsIcon from "@/media/ttsproviders/elevenlabs.png";
+import PiperTTSIcon from "@/media/ttsproviders/piper.png";
+import GenericOpenAiLogo from "@/media/ttsproviders/generic-openai.png";
+
+import BrowserNative from "@/components/TextToSpeech/BrowserNative";
+import OpenAiTTSOptions from "@/components/TextToSpeech/OpenAiOptions";
+import ElevenLabsTTSOptions from "@/components/TextToSpeech/ElevenLabsOptions";
+import PiperTTSOptions from "@/components/TextToSpeech/PiperTTSOptions";
+import OpenAiGenericTTSOptions from "@/components/TextToSpeech/OpenAiGenericOptions";
+
+const PROVIDERS = [
+ {
+ name: "System native",
+ value: "native",
+ logo: AnythingLLMIcon,
+ options: (settings) =>
,
+ description: "Uses your browser's built in TTS service if supported.",
+ },
+ {
+ name: "OpenAI",
+ value: "openai",
+ logo: OpenAiLogo,
+ options: (settings) =>
,
+ description: "Use OpenAI's text to speech voices.",
+ },
+ {
+ name: "ElevenLabs",
+ value: "elevenlabs",
+ logo: ElevenLabsIcon,
+ options: (settings) =>
,
+ description: "Use ElevenLabs's text to speech voices and technology.",
+ },
+ {
+ name: "PiperTTS",
+ value: "piper_local",
+ logo: PiperTTSIcon,
+ options: (settings) =>
,
+ description: "Run TTS models locally in your browser privately.",
+ },
+ {
+ name: "OpenAI Compatible",
+ value: "generic-openai",
+ logo: GenericOpenAiLogo,
+ options: (settings) =>
,
+ description:
+ "Connect to an OpenAI compatible TTS service running locally or remotely.",
+ },
+];
+
+export default function TextToSpeechProvider({ settings }) {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredProviders, setFilteredProviders] = useState([]);
+ const [selectedProvider, setSelectedProvider] = useState(
+ settings?.TextToSpeechProvider || "native"
+ );
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+
+ const handleSubmit = async (e) => {
+ e?.preventDefault();
+ const form = e.target;
+ const data = { TextToSpeechProvider: selectedProvider };
+ const formData = new FormData(form);
+
+ for (var [key, value] of formData.entries()) data[key] = value;
+ const { error } = await System.updateSystem(data);
+ setSaving(true);
+
+ if (error) {
+ showToast(`Failed to save preferences: ${error}`, "error");
+ } else {
+ showToast("Text-to-speech preferences saved successfully.", "success");
+ }
+ setSaving(false);
+ setHasChanges(!!error);
+ };
+
+ const updateProviderChoice = (selection) => {
+ setSearchQuery("");
+ setSelectedProvider(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ };
+
+ const handleXButton = () => {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ };
+
+ useEffect(() => {
+ const filtered = PROVIDERS.filter((provider) =>
+ provider.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredProviders(filtered);
+ }, [searchQuery, selectedProvider]);
+
+ const selectedProviderObject = PROVIDERS.find(
+ (provider) => provider.value === selectedProvider
+ );
+
+ return (
+
+
+
+
+
+ Text-to-speech Preference
+
+
+
+ Here you can specify what kind of text-to-speech providers you would
+ want to use in your AnythingLLM experience. By default, we use the
+ browser's built in support for these services, but you may want to
+ use others.
+
+
+
+ {hasChanges && (
+
+ {saving ? "Saving..." : "Save changes"}
+
+ )}
+
+
Provider
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredProviders.map((provider) => (
+ updateProviderChoice(provider.value)}
+ />
+ ))}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedProviderObject.name}
+
+
+ {selectedProviderObject.description}
+
+
+
+
+
+ )}
+
+
setHasChanges(true)}
+ className="mt-4 flex flex-col gap-y-1"
+ >
+ {selectedProvider &&
+ PROVIDERS.find(
+ (provider) => provider.value === selectedProvider
+ )?.options(settings)}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..902cd15f5dd9e85d79608e554d4cb9764f30ace1
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx
@@ -0,0 +1,109 @@
+import { useRef, useState } from "react";
+import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
+import showToast from "@/utils/toast";
+import { Trash, Copy, Check, Plug } from "@phosphor-icons/react";
+import { POPUP_BROWSER_EXTENSION_EVENT } from "@/utils/constants";
+
+export default function BrowserExtensionApiKeyRow({
+ apiKey,
+ removeApiKey,
+ connectionString,
+ isMultiUser,
+}) {
+ const rowRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+
+ const handleRevoke = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to revoke this browser extension API key?\nAfter you do this it will no longer be useable.\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+
+ const result = await BrowserExtensionApiKey.revoke(apiKey.id);
+ if (result.success) {
+ removeApiKey(apiKey.id);
+ showToast("Browser Extension API Key permanently revoked", "info", {
+ clear: true,
+ });
+ } else {
+ showToast("Failed to revoke API Key", "error", {
+ clear: true,
+ });
+ }
+ };
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(connectionString);
+ showToast("Connection string copied to clipboard", "success", {
+ clear: true,
+ });
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const handleConnect = () => {
+ // Sending a message to Chrome extension to pop up the extension window
+ // This will open the extension window and attempt to connect with the API key
+ window.postMessage(
+ { type: POPUP_BROWSER_EXTENSION_EVENT, apiKey: connectionString },
+ "*"
+ );
+ showToast("Attempting to connect to browser extension...", "info", {
+ clear: true,
+ });
+ };
+
+ return (
+
+
+
+
{connectionString}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {isMultiUser && (
+
+ {apiKey.user ? apiKey.user.username : "N/A"}
+
+ )}
+
+ {new Date(apiKey.createdAt).toLocaleString()}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..738201e2fe3d702454ae3f79e8fdb81921d30480
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx
@@ -0,0 +1,129 @@
+import React, { useEffect, useState } from "react";
+import { X } from "@phosphor-icons/react";
+import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
+import { fullApiUrl, POPUP_BROWSER_EXTENSION_EVENT } from "@/utils/constants";
+
+export default function NewBrowserExtensionApiKeyModal({
+ closeModal,
+ onSuccess,
+ isMultiUser,
+}) {
+ const [apiKey, setApiKey] = useState(null);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const handleCreate = async (e) => {
+ setError(null);
+ e.preventDefault();
+
+ const { apiKey: newApiKey, error } =
+ await BrowserExtensionApiKey.generateKey();
+ if (!!newApiKey) {
+ const fullApiKey = `${fullApiUrl()}|${newApiKey}`;
+ setApiKey(fullApiKey);
+ onSuccess();
+
+ window.postMessage(
+ { type: POPUP_BROWSER_EXTENSION_EVENT, apiKey: fullApiKey },
+ "*"
+ );
+ }
+ setError(error);
+ };
+
+ const copyApiKey = () => {
+ if (!apiKey) return false;
+ window.navigator.clipboard.writeText(apiKey);
+ setCopied(true);
+ };
+
+ useEffect(() => {
+ function resetStatus() {
+ if (!copied) return false;
+ setTimeout(() => {
+ setCopied(false);
+ }, 3000);
+ }
+ resetStatus();
+ }, [copied]);
+
+ return (
+
+
+
+
+
+ New Browser Extension API Key
+
+
+
+
+
+
+
+
+
+ {error &&
Error: {error}
}
+ {apiKey && (
+
+ )}
+ {isMultiUser && (
+
+ Warning: You are in multi-user mode, this API key will allow
+ access to all workspaces associated with your account. Please
+ share it cautiously.
+
+ )}
+
+ After clicking "Create API Key", AnythingLLM will attempt to
+ connect to your browser extension automatically.
+
+
+ If you see "Connected to AnythingLLM" in the extension, the
+ connection was successful. If not, please copy the connection
+ string and paste it into the extension manually.
+
+
+
+ {!apiKey ? (
+ <>
+
+ Cancel
+
+
+ Create API Key
+
+ >
+ ) : (
+
+ {copied ? "API Key Copied!" : "Copy API Key"}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..fdb4f071e116b0bd6b4cf6f23cdc36c14348e63d
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx
@@ -0,0 +1,151 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { PlusCircle } from "@phosphor-icons/react";
+import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
+import BrowserExtensionApiKeyRow from "./BrowserExtensionApiKeyRow";
+import CTAButton from "@/components/lib/CTAButton";
+import NewBrowserExtensionApiKeyModal from "./NewBrowserExtensionApiKeyModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import { fullApiUrl } from "@/utils/constants";
+import { Tooltip } from "react-tooltip";
+
+export default function BrowserExtensionApiKeys() {
+ const [loading, setLoading] = useState(true);
+ const [apiKeys, setApiKeys] = useState([]);
+ const [error, setError] = useState(null);
+ const { isOpen, openModal, closeModal } = useModal();
+ const [isMultiUser, setIsMultiUser] = useState(false);
+
+ useEffect(() => {
+ fetchExistingKeys();
+ }, []);
+
+ const fetchExistingKeys = async () => {
+ const result = await BrowserExtensionApiKey.getAll();
+ if (result.success) {
+ setApiKeys(result.apiKeys);
+ setIsMultiUser(result.apiKeys.some((key) => key.user !== null));
+ } else {
+ setError(result.error || "Failed to fetch API keys");
+ }
+ setLoading(false);
+ };
+
+ const removeApiKey = (id) => {
+ setApiKeys((prevKeys) => prevKeys.filter((apiKey) => apiKey.id !== id));
+ };
+
+ return (
+
+
+
+
+
+
+
+ Browser Extension API Keys
+
+
+
+ Manage API keys for browser extensions connecting to your
+ AnythingLLM instance.
+
+
+
+
+
+ Generate New API Key
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
Error: {error}
+ ) : (
+
+
+
+
+ Extension Connection String
+
+ {isMultiUser && (
+
+ Created By
+
+ )}
+
+ Created At
+
+
+ Actions
+
+
+
+
+ {apiKeys.length === 0 ? (
+
+
+ No API keys found
+
+
+ ) : (
+ apiKeys.map((apiKey) => (
+
+ ))
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/ChatRow/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/ChatRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bf50e61f57d4994dae040b256c00a8ea676e50a0
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/ChatRow/index.jsx
@@ -0,0 +1,182 @@
+import truncate from "truncate";
+import { X } from "@phosphor-icons/react";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import paths from "@/utils/paths";
+import Embed from "@/models/embed";
+import MarkdownRenderer from "../MarkdownRenderer";
+import { safeJsonParse } from "@/utils/request";
+
+export default function ChatRow({ chat, onDelete }) {
+ const {
+ isOpen: isPromptOpen,
+ openModal: openPromptModal,
+ closeModal: closePromptModal,
+ } = useModal();
+ const {
+ isOpen: isResponseOpen,
+ openModal: openResponseModal,
+ closeModal: closeResponseModal,
+ } = useModal();
+ const {
+ isOpen: isConnectionDetailsModalOpen,
+ openModal: openConnectionDetailsModal,
+ closeModal: closeConnectionDetailsModal,
+ } = useModal();
+
+ const handleDelete = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to delete this chat?\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+ await Embed.deleteChat(chat.id);
+ onDelete(chat.id);
+ };
+
+ return (
+ <>
+
+
+
+ {chat.embed_config.workspace.name}
+
+
+
+
+
{truncate(chat.session_id, 20)}
+
+
+
+ {truncate(chat.prompt, 40)}
+
+
+ {truncate(JSON.parse(chat.response)?.text, 40)}
+
+ {chat.createdAt}
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+ }
+ closeModal={closeResponseModal}
+ />
+
+
+
+ }
+ closeModal={closeConnectionDetailsModal}
+ />
+
+ >
+ );
+}
+
+const TextPreview = ({ text, closeModal }) => {
+ return (
+
+
+
+
Viewing Text
+
+
+
+
+
+
+
+ );
+};
+
+const ConnectionDetails = ({
+ sessionId,
+ verbose = false,
+ connection_information,
+}) => {
+ const details = safeJsonParse(connection_information, {});
+ if (Object.keys(details).length === 0) return null;
+
+ if (verbose) {
+ return (
+ <>
+
+ sessionID: {sessionId}
+
+ {details.username && (
+
+ username: {details.username}
+
+ )}
+ {details.ip && (
+
+ client ip address: {details.ip}
+
+ )}
+ {details.host && (
+
+ client host URL: {details.host}
+
+ )}
+ >
+ );
+ }
+
+ return (
+ <>
+ {details.username && (
+
{details.username}
+ )}
+ {details.ip && (
+
{details.ip}
+ )}
+ {details.host && (
+
{details.host}
+ )}
+ >
+ );
+};
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/MarkdownRenderer.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/MarkdownRenderer.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..11b4ca51c1e5cda07d873e4f235bb8ab9c81969f
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/MarkdownRenderer.jsx
@@ -0,0 +1,87 @@
+import { useState } from "react";
+import MarkdownIt from "markdown-it";
+import { CaretDown } from "@phosphor-icons/react";
+import "highlight.js/styles/github-dark.css";
+import DOMPurify from "@/utils/chat/purify";
+
+const md = new MarkdownIt({
+ html: true,
+ breaks: true,
+ highlight: function (str, lang) {
+ if (lang && hljs.getLanguage(lang)) {
+ try {
+ return hljs.highlight(str, { language: lang }).value;
+ } catch (__) {}
+ }
+ return ""; // use external default escaping
+ },
+});
+
+const ThoughtBubble = ({ thought }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ if (!thought) return null;
+
+ const cleanThought = thought.replace(/<\/?think>/g, "").trim();
+ if (!cleanThought) return null;
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ className="cursor-pointer flex items-center gap-x-2 text-theme-text-secondary hover:text-theme-text-primary transition-colors mb-2"
+ >
+
+ View thoughts
+
+ {isExpanded && (
+
+ )}
+
+ );
+};
+
+function parseContent(content) {
+ const parts = [];
+ let lastIndex = 0;
+ content.replace(/
([^]*?)<\/think>/g, (match, thinkContent, offset) => {
+ if (offset > lastIndex) {
+ parts.push({ type: "normal", text: content.slice(lastIndex, offset) });
+ }
+ parts.push({ type: "think", text: thinkContent });
+ lastIndex = offset + match.length;
+ });
+ if (lastIndex < content.length) {
+ parts.push({ type: "normal", text: content.slice(lastIndex) });
+ }
+ return parts;
+}
+
+export default function MarkdownRenderer({ content }) {
+ if (!content) return null;
+
+ const parts = parseContent(content);
+ return (
+
+ {parts.map((part, index) => {
+ const html = md.render(part.text);
+ if (part.type === "think")
+ return
;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cd242422e7148ece946326fac7aff021ef60c91b
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/index.jsx
@@ -0,0 +1,238 @@
+import { useEffect, useState, useRef } from "react";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import useQuery from "@/hooks/useQuery";
+import ChatRow from "./ChatRow";
+import Embed from "@/models/embed";
+import { useTranslation } from "react-i18next";
+import { CaretDown, Download } from "@phosphor-icons/react";
+import showToast from "@/utils/toast";
+import { saveAs } from "file-saver";
+import System from "@/models/system";
+
+const exportOptions = {
+ csv: {
+ name: "CSV",
+ mimeType: "text/csv",
+ fileExtension: "csv",
+ filenameFunc: () => {
+ return `anythingllm-embed-chats-${new Date().toLocaleDateString()}`;
+ },
+ },
+ json: {
+ name: "JSON",
+ mimeType: "application/json",
+ fileExtension: "json",
+ filenameFunc: () => {
+ return `anythingllm-embed-chats-${new Date().toLocaleDateString()}`;
+ },
+ },
+ jsonl: {
+ name: "JSONL",
+ mimeType: "application/jsonl",
+ fileExtension: "jsonl",
+ filenameFunc: () => {
+ return `anythingllm-embed-chats-${new Date().toLocaleDateString()}-lines`;
+ },
+ },
+ jsonAlpaca: {
+ name: "JSON (Alpaca)",
+ mimeType: "application/json",
+ fileExtension: "json",
+ filenameFunc: () => {
+ return `anythingllm-embed-chats-${new Date().toLocaleDateString()}-alpaca`;
+ },
+ },
+};
+
+export default function EmbedChatsView() {
+ const [showMenu, setShowMenu] = useState(false);
+ const menuRef = useRef();
+ const openMenuButton = useRef();
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(true);
+ const [chats, setChats] = useState([]);
+ const query = useQuery();
+ const [offset, setOffset] = useState(Number(query.get("offset") || 0));
+ const [canNext, setCanNext] = useState(false);
+ const [showThinking, setShowThinking] = useState(true);
+
+ const handleDumpChats = async (exportType) => {
+ const chats = await System.exportChats(exportType, "embed");
+ if (!!chats) {
+ const { name, mimeType, fileExtension, filenameFunc } =
+ exportOptions[exportType];
+ const blob = new Blob([chats], { type: mimeType });
+ saveAs(blob, `${filenameFunc()}.${fileExtension}`);
+ showToast(`Embed chats exported successfully as ${name}.`, "success");
+ } else {
+ showToast("Failed to export embed chats.", "error");
+ }
+ };
+
+ const toggleMenu = () => {
+ setShowMenu(!showMenu);
+ };
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (
+ menuRef.current &&
+ !menuRef.current.contains(event.target) &&
+ !openMenuButton.current.contains(event.target)
+ ) {
+ setShowMenu(false);
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ useEffect(() => {
+ async function fetchChats() {
+ setLoading(true);
+ await Embed.chats(offset)
+ .then(({ chats: _chats, hasPages = false }) => {
+ setChats(_chats);
+ setCanNext(hasPages);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+ fetchChats();
+ }, [offset]);
+
+ const handlePrevious = () => {
+ setOffset(Math.max(offset - 1, 0));
+ };
+
+ const handleNext = () => {
+ setOffset(offset + 1);
+ };
+
+ const handleDeleteChat = (chatId) => {
+ setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatId));
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t("embed-chats.title")}
+
+
+
+
+ {t("embed-chats.export")}
+
+
+
+
+ {Object.entries(exportOptions).map(([key, data]) => (
+ {
+ handleDumpChats(key);
+ setShowMenu(false);
+ }}
+ className="w-full text-left px-4 py-2 text-white text-sm hover:bg-[#3D4147] light:hover:bg-theme-sidebar-item-hover"
+ >
+ {data.name}
+
+ ))}
+
+
+
+
+
+ {t("embed-chats.description")}
+
+
+
+
+
+
+
+ {t("embed-chats.table.embed")}
+
+
+ {t("embed-chats.table.sender")}
+
+
+ {t("embed-chats.table.message")}
+
+
+ {t("embed-chats.table.response")}
+
+
+ {t("embed-chats.table.at")}
+
+
+ {" "}
+
+
+
+
+ {chats.map((chat) => (
+
+ ))}
+
+
+ {(offset > 0 || canNext) && (
+
+
+ {t("common.previous")}
+
+
+ {t("common.next")}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/CodeSnippetModal/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/CodeSnippetModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..07418ceb1e50b60eb8046c0057fc1b1eb847dd27
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/CodeSnippetModal/index.jsx
@@ -0,0 +1,126 @@
+import React, { useState } from "react";
+import { CheckCircle, CopySimple, X } from "@phosphor-icons/react";
+import showToast from "@/utils/toast";
+import hljs from "highlight.js";
+import "@/utils/chat/themes/github-dark.css";
+import "@/utils/chat/themes/github.css";
+
+export default function CodeSnippetModal({ embed, closeModal }) {
+ return (
+
+
+
+
+
+ Copy your embed code
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function createScriptTagSnippet(embed, scriptHost, serverHost) {
+ return `
+
+
+`;
+}
+
+const ScriptTag = ({ embed }) => {
+ const [copied, setCopied] = useState(false);
+ const scriptHost = import.meta.env.DEV
+ ? "http://localhost:3000"
+ : window.location.origin;
+ const serverHost = import.meta.env.DEV
+ ? "http://localhost:3001"
+ : window.location.origin;
+ const snippet = createScriptTagSnippet(embed, scriptHost, serverHost);
+ const theme =
+ window.localStorage.getItem("theme") === "light" ? "github" : "github-dark";
+
+ const handleClick = () => {
+ window.navigator.clipboard.writeText(snippet);
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2500);
+ showToast("Snippet copied to clipboard!", "success", { clear: true });
+ };
+
+ return (
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/EditEmbedModal/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/EditEmbedModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ef615ab10ddc407abaabfdc41624fcd65393eb32
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/EditEmbedModal/index.jsx
@@ -0,0 +1,128 @@
+import React, { useState } from "react";
+import { X } from "@phosphor-icons/react";
+import {
+ BooleanInput,
+ ChatModeSelection,
+ NumberInput,
+ PermittedDomains,
+ WorkspaceSelection,
+ enforceSubmissionSchema,
+} from "../../NewEmbedModal";
+import Embed from "@/models/embed";
+import showToast from "@/utils/toast";
+
+export default function EditEmbedModal({ embed, closeModal }) {
+ const [error, setError] = useState(null);
+
+ const handleUpdate = async (e) => {
+ setError(null);
+ e.preventDefault();
+ const form = new FormData(e.target);
+ const data = enforceSubmissionSchema(form);
+ const { success, error } = await Embed.updateEmbed(embed.id, data);
+ if (success) {
+ showToast("Embed updated successfully.", "success", { clear: true });
+ setTimeout(() => {
+ window.location.reload();
+ }, 800);
+ }
+ setError(error);
+ };
+
+ return (
+
+
+
+
+
+ Update embed #{embed.id}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error &&
Error: {error}
}
+
+ After creating an embed you will be provided a link that you can
+ publish on your website with a simple
+
+ <script>
+ {" "}
+ tag.
+
+
+
+
+ Cancel
+
+
+ Update embed
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..10fa42db1ac54d5b2dbf69d0bd3656db41554cbe
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/EmbedRow/index.jsx
@@ -0,0 +1,160 @@
+import { useRef, useState } from "react";
+import { DotsThreeOutline } from "@phosphor-icons/react";
+import showToast from "@/utils/toast";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import Embed from "@/models/embed";
+import paths from "@/utils/paths";
+import { nFormatter } from "@/utils/numbers";
+import EditEmbedModal from "./EditEmbedModal";
+import CodeSnippetModal from "./CodeSnippetModal";
+import moment from "moment";
+
+export default function EmbedRow({ embed }) {
+ const rowRef = useRef(null);
+ const [enabled, setEnabled] = useState(Number(embed.enabled) === 1);
+ const {
+ isOpen: isSettingsOpen,
+ openModal: openSettingsModal,
+ closeModal: closeSettingsModal,
+ } = useModal();
+ const {
+ isOpen: isSnippetOpen,
+ openModal: openSnippetModal,
+ closeModal: closeSnippetModal,
+ } = useModal();
+
+ const handleSuspend = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to disabled this embed?\nOnce disabled the embed will no longer respond to any chat requests.`
+ )
+ )
+ return false;
+
+ const { success, error } = await Embed.updateEmbed(embed.id, {
+ enabled: !enabled,
+ });
+ if (!success) showToast(error, "error", { clear: true });
+ if (success) {
+ showToast(
+ `Embed ${enabled ? "has been disabled" : "is active"}.`,
+ "success",
+ { clear: true }
+ );
+ setEnabled(!enabled);
+ }
+ };
+ const handleDelete = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to delete this embed?\nOnce deleted this embed will no longer respond to chats or be active.\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+ const { success, error } = await Embed.deleteEmbed(embed.id);
+ if (!success) showToast(error, "error", { clear: true });
+ if (success) {
+ rowRef?.current?.remove();
+ showToast("Embed deleted from system.", "success", { clear: true });
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {embed.workspace.name}
+
+
+
+ {nFormatter(embed._count.embed_chats)}
+
+
+
+
+
+ {
+ // If the embed was created more than a day ago, show the date, otherwise show the time ago
+ moment(embed.createdAt).diff(moment(), "days") > 0
+ ? moment(embed.createdAt).format("MMM D, YYYY")
+ : moment(embed.createdAt).fromNow()
+ }
+
+
+
+
+ Code
+
+
+
+
+ {enabled ? "Disable" : "Enable"}
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function ActiveDomains({ domainList }) {
+ if (!domainList) return all
;
+ try {
+ const domains = JSON.parse(domainList);
+ return (
+
+ {domains.map((domain, index) => {
+ return (
+
+ {domain}
+
+ );
+ })}
+
+ );
+ } catch {
+ return all
;
+ }
+}
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/NewEmbedModal/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/NewEmbedModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..46bd111974cdec720837f26dfb6e2490f9ce1936
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/NewEmbedModal/index.jsx
@@ -0,0 +1,365 @@
+import React, { useEffect, useState } from "react";
+import { X } from "@phosphor-icons/react";
+import Workspace from "@/models/workspace";
+import { TagsInput } from "react-tag-input-component";
+import Embed from "@/models/embed";
+
+export function enforceSubmissionSchema(form) {
+ const data = {};
+ for (var [key, value] of form.entries()) {
+ if (!value || value === null) continue;
+ data[key] = value;
+ if (value === "on") data[key] = true;
+ }
+
+ // Always set value on nullable keys since empty or off will not send anything from form element.
+ if (!data.hasOwnProperty("allowlist_domains")) data.allowlist_domains = null;
+ if (!data.hasOwnProperty("allow_model_override"))
+ data.allow_model_override = false;
+ if (!data.hasOwnProperty("allow_temperature_override"))
+ data.allow_temperature_override = false;
+ if (!data.hasOwnProperty("allow_prompt_override"))
+ data.allow_prompt_override = false;
+ if (!data.hasOwnProperty("message_limit")) data.message_limit = 20;
+ return data;
+}
+
+export default function NewEmbedModal({ closeModal }) {
+ const [error, setError] = useState(null);
+
+ const handleCreate = async (e) => {
+ setError(null);
+ e.preventDefault();
+ const form = new FormData(e.target);
+ const data = enforceSubmissionSchema(form);
+ const { embed, error } = await Embed.newEmbed(data);
+ if (!!embed) window.location.reload();
+ setError(error);
+ };
+
+ return (
+
+
+
+
+
+ Create new embed for workspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error &&
Error: {error}
}
+
+ After creating an embed you will be provided a link that you can
+ publish on your website with a simple
+
+ <script>
+ {" "}
+ tag.
+
+
+
+
+ Cancel
+
+
+ Create embed
+
+
+
+
+
+
+ );
+}
+
+export const WorkspaceSelection = ({ defaultValue = null }) => {
+ const [workspaces, setWorkspaces] = useState([]);
+ useEffect(() => {
+ async function fetchWorkspaces() {
+ const _workspaces = await Workspace.all();
+ setWorkspaces(_workspaces);
+ }
+ fetchWorkspaces();
+ }, []);
+
+ return (
+
+
+
+ Workspace
+
+
+ This is the workspace your chat window will be based on. All defaults
+ will be inherited from the workspace unless overridden by this config.
+
+
+
+ {workspaces.map((workspace) => {
+ return (
+
+ {workspace.name}
+
+ );
+ })}
+
+
+ );
+};
+
+export const ChatModeSelection = ({ defaultValue = null }) => {
+ const [chatMode, setChatMode] = useState(defaultValue ?? "query");
+
+ return (
+
+
+
+ Allowed chat method
+
+
+ Set how your chatbot should operate. Query means it will only respond
+ if a document helps answer the query.
+
+ Chat opens the chat to even general questions and can answer totally
+ unrelated queries to your workspace.
+
+
+
+
+ setChatMode(e.target.value)}
+ className="hidden"
+ />
+
+
+ Chat: Respond to all questions regardless of context
+
+
+
+ setChatMode(e.target.value)}
+ className="hidden"
+ />
+
+
+ Query: Only respond to chats related to documents in workspace
+
+
+
+
+ );
+};
+
+export const PermittedDomains = ({ defaultValue = [] }) => {
+ const [domains, setDomains] = useState(defaultValue);
+ const handleChange = (data) => {
+ const validDomains = data
+ .map((input) => {
+ let url = input;
+ if (!url.includes("http://") && !url.includes("https://"))
+ url = `https://${url}`;
+ try {
+ new URL(url);
+ return url;
+ } catch {
+ return null;
+ }
+ })
+ .filter((u) => !!u);
+ setDomains(validDomains);
+ };
+
+ const handleBlur = (event) => {
+ const currentInput = event.target.value;
+ if (!currentInput) return;
+
+ const validDomains = [...domains, currentInput].map((input) => {
+ let url = input;
+ if (!url.includes("http://") && !url.includes("https://"))
+ url = `https://${url}`;
+ try {
+ new URL(url);
+ return url;
+ } catch {
+ return null;
+ }
+ });
+ event.target.value = "";
+ setDomains(validDomains);
+ };
+
+ return (
+
+
+
+ Restrict requests from domains
+
+
+ This filter will block any requests that come from a domain other than
+ the list below.
+
+ Leaving this empty means anyone can use your embed on any site.
+
+
+
+
+
+ );
+};
+
+export const NumberInput = ({ name, title, hint, defaultValue = 0 }) => {
+ return (
+
+
+
e.target.blur()}
+ />
+
+ );
+};
+
+export const BooleanInput = ({ name, title, hint, defaultValue = null }) => {
+ const [status, setStatus] = useState(defaultValue ?? false);
+
+ return (
+
+
+
+ setStatus(!status)}
+ checked={status}
+ className="peer sr-only pointer-events-none"
+ />
+
+
+
+ );
+};
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..236c95a7987d3ee76c8329d1bb51bce02a79515c
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/index.jsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { CodeBlock } from "@phosphor-icons/react";
+import EmbedRow from "./EmbedRow";
+import NewEmbedModal from "./NewEmbedModal";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import Embed from "@/models/embed";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function EmbedConfigsView() {
+ const { isOpen, openModal, closeModal } = useModal();
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(true);
+ const [embeds, setEmbeds] = useState([]);
+
+ useEffect(() => {
+ async function fetchUsers() {
+ const _embeds = await Embed.embeds();
+ setEmbeds(_embeds);
+ setLoading(false);
+ }
+ fetchUsers();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t("embeddable.title")}
+
+
+
+
+
+ {t("embeddable.description")}
+
+
+
+
+ {" "}
+ {t("embeddable.create")}
+
+
+
+
+
+
+
+
+
+ {t("embeddable.table.workspace")}
+
+
+ {t("embeddable.table.chats")}
+
+
+ {t("embeddable.table.active")}
+
+
+ {t("embeddable.table.created")}
+
+
+ {" "}
+
+
+
+
+ {embeds.map((embed) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d094579715fd49533938374c6e1969184df5361b
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/index.jsx
@@ -0,0 +1,156 @@
+import { useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import { CaretLeft, CaretRight } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+import EmbedConfigsView from "./EmbedConfigs";
+import EmbedChatsView from "./EmbedChats";
+
+export default function ChatEmbedWidgets() {
+ const { t } = useTranslation();
+ const [selectedView, setSelectedView] = useState("configs");
+ const [showViewModal, setShowViewModal] = useState(false);
+
+ if (isMobile) {
+ return (
+
+
+
+
+
{
+ setSelectedView(view);
+ setShowViewModal(true);
+ }}
+ />
+
+ {showViewModal && (
+
+
+
+
{
+ setShowViewModal(false);
+ setSelectedView("");
+ }}
+ className="text-white/60 hover:text-white transition-colors duration-200"
+ >
+
+
+
+
+
+ {selectedView === "configs" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {selectedView === "configs" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+function WidgetLayout({ children }) {
+ return (
+
+ );
+}
+
+function WidgetList({ selectedView, handleClick }) {
+ const views = {
+ configs: {
+ title: "Widgets",
+ },
+ chats: {
+ title: "History",
+ },
+ };
+
+ return (
+
+ {Object.entries(views).map(([view, settings], index) => (
+
handleClick?.(view)}
+ >
+
{settings.title}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Chats/ChatRow/index.jsx b/frontend/src/pages/GeneralSettings/Chats/ChatRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..52cb02dcd7a50385f559b0461cee8b288ab3afe1
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Chats/ChatRow/index.jsx
@@ -0,0 +1,112 @@
+import truncate from "truncate";
+import { X, Trash } from "@phosphor-icons/react";
+import System from "@/models/system";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+
+// Some LLMs may return a "valid" response that truncation fails to truncate because
+// it stored an Object as opposed to a string for the `text` field.
+function parseText(jsonResponse = "") {
+ try {
+ const json = JSON.parse(jsonResponse);
+ if (!json.hasOwnProperty("text"))
+ throw new Error('JSON response has no property "text".');
+ return typeof json.text !== "string"
+ ? JSON.stringify(json.text)
+ : json.text;
+ } catch (e) {
+ console.error(e);
+ return "--failed to parse--";
+ }
+}
+
+export default function ChatRow({ chat, onDelete }) {
+ const {
+ isOpen: isPromptOpen,
+ openModal: openPromptModal,
+ closeModal: closePromptModal,
+ } = useModal();
+ const {
+ isOpen: isResponseOpen,
+ openModal: openResponseModal,
+ closeModal: closeResponseModal,
+ } = useModal();
+
+ const handleDelete = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to delete this chat?\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+ await System.deleteChat(chat.id);
+ onDelete(chat.id);
+ };
+
+ return (
+ <>
+
+
+ {chat.id}
+
+
+ {chat.user?.username}
+
+ {chat.workspace?.name}
+
+ {truncate(chat.prompt, 40)}
+
+
+ {truncate(parseText(chat.response), 40)}
+
+ {chat.createdAt}
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+const TextPreview = ({ text, closeModal }) => {
+ return (
+
+
+
+
Viewing Text
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/GeneralSettings/Chats/index.jsx b/frontend/src/pages/GeneralSettings/Chats/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..68d981c1e2a8556f5e1b22d4dbc20e7882c2e08c
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Chats/index.jsx
@@ -0,0 +1,285 @@
+import { useEffect, useRef, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import useQuery from "@/hooks/useQuery";
+import ChatRow from "./ChatRow";
+import showToast from "@/utils/toast";
+import System from "@/models/system";
+import { CaretDown, Download, Trash } from "@phosphor-icons/react";
+import { saveAs } from "file-saver";
+import { useTranslation } from "react-i18next";
+import { CanViewChatHistory } from "@/components/CanViewChatHistory";
+
+const exportOptions = {
+ csv: {
+ name: "CSV",
+ mimeType: "text/csv",
+ fileExtension: "csv",
+ filenameFunc: () => {
+ return `anythingllm-chats-${new Date().toLocaleDateString()}`;
+ },
+ },
+ json: {
+ name: "JSON",
+ mimeType: "application/json",
+ fileExtension: "json",
+ filenameFunc: () => {
+ return `anythingllm-chats-${new Date().toLocaleDateString()}`;
+ },
+ },
+ jsonl: {
+ name: "JSONL",
+ mimeType: "application/jsonl",
+ fileExtension: "jsonl",
+ filenameFunc: () => {
+ return `anythingllm-chats-${new Date().toLocaleDateString()}-lines`;
+ },
+ },
+ jsonAlpaca: {
+ name: "JSON (Alpaca)",
+ mimeType: "application/json",
+ fileExtension: "json",
+ filenameFunc: () => {
+ return `anythingllm-chats-${new Date().toLocaleDateString()}-alpaca`;
+ },
+ },
+};
+
+export default function WorkspaceChats() {
+ const [showMenu, setShowMenu] = useState(false);
+ const menuRef = useRef();
+ const openMenuButton = useRef();
+ const query = useQuery();
+ const [loading, setLoading] = useState(true);
+ const [chats, setChats] = useState([]);
+ const [offset, setOffset] = useState(Number(query.get("offset") || 0));
+ const [canNext, setCanNext] = useState(false);
+ const { t } = useTranslation();
+
+ const handleDumpChats = async (exportType) => {
+ const chats = await System.exportChats(exportType, "workspace");
+ if (!!chats) {
+ const { name, mimeType, fileExtension, filenameFunc } =
+ exportOptions[exportType];
+ const blob = new Blob([chats], { type: mimeType });
+ saveAs(blob, `${filenameFunc()}.${fileExtension}`);
+ showToast(`Chats exported successfully as ${name}.`, "success");
+ } else {
+ showToast("Failed to export chats.", "error");
+ }
+ };
+
+ const handleClearAllChats = async () => {
+ if (
+ !window.confirm(
+ `Are you sure you want to clear all chats?\n\nThis action is irreversible.`
+ )
+ )
+ return false;
+ await System.deleteChat(-1);
+ setChats([]);
+ showToast("Cleared all chats.", "success");
+ };
+
+ const toggleMenu = () => {
+ setShowMenu(!showMenu);
+ };
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (
+ menuRef.current &&
+ !menuRef.current.contains(event.target) &&
+ !openMenuButton.current.contains(event.target)
+ ) {
+ setShowMenu(false);
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ useEffect(() => {
+ async function fetchChats() {
+ const { chats: _chats = [], hasPages = false } =
+ await System.chats(offset);
+ setChats(_chats);
+ setCanNext(hasPages);
+ setLoading(false);
+ }
+ fetchChats();
+ }, [offset]);
+
+ return (
+
+
+
+
+
+
+
+
+ {t("recorded.title")}
+
+
+
+
+ {t("recorded.export")}
+
+
+
+
+ {Object.entries(exportOptions).map(([key, data]) => (
+ {
+ handleDumpChats(key);
+ setShowMenu(false);
+ }}
+ className="w-full text-left px-4 py-2 text-white text-sm hover:bg-[#3D4147] light:hover:bg-theme-sidebar-item-hover"
+ >
+ {data.name}
+
+ ))}
+
+
+
+ {chats.length > 0 && (
+
+
+ Clear Chats
+
+ )}
+
+
+ {t("recorded.description")}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ChatsContainer({
+ loading,
+ chats,
+ setChats,
+ offset,
+ setOffset,
+ canNext,
+ t,
+}) {
+ const handlePrevious = () => {
+ setOffset(Math.max(offset - 1, 0));
+ };
+ const handleNext = () => {
+ setOffset(offset + 1);
+ };
+
+ const handleDeleteChat = async (chatId) => {
+ await System.deleteChat(chatId);
+ setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatId));
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {t("recorded.table.id")}
+
+
+ {t("recorded.table.by")}
+
+
+ {t("recorded.table.workspace")}
+
+
+ {t("recorded.table.prompt")}
+
+
+ {t("recorded.table.response")}
+
+
+ {t("recorded.table.at")}
+
+
+ {" "}
+
+
+
+
+ {!!chats &&
+ chats.map((chat) => (
+
+ ))}
+
+
+
+
+ {" "}
+ Previous Page
+
+
+ Next Page
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/UserItems/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/UserItems/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..89457542e89c604b2af81b175311c3a428cc91ac
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/UserItems/index.jsx
@@ -0,0 +1,103 @@
+import paths from "@/utils/paths";
+import HubItemCard from "../../Trending/HubItems/HubItemCard";
+import { useUserItems } from "../useUserItems";
+import { HubItemCardSkeleton } from "../../Trending/HubItems";
+import { readableType } from "../../utils";
+
+export default function UserItems({ connectionKey }) {
+ const { loading, userItems } = useUserItems({ connectionKey });
+ const { createdByMe = {}, teamItems = [] } = userItems || {};
+
+ if (loading) return ;
+ const hasItems = (items) => {
+ return Object.values(items).some((category) => category?.items?.length > 0);
+ };
+
+ return (
+
+ {/* Created By Me Section */}
+
+
+
+ Items you have created and shared publicly on the AnythingLLM
+ Community Hub.
+
+
+ {Object.keys(createdByMe).map((type) => {
+ if (!createdByMe[type]?.items?.length) return null;
+ return (
+
+
+ {readableType(type)}
+
+
+ {createdByMe[type].items.map((item) => (
+
+ ))}
+
+
+ );
+ })}
+ {!hasItems(createdByMe) && (
+
+ You haven't created any items yet.
+
+ )}
+
+
+
+ {/* Team Items Section */}
+
+
+
+ Public and private items shared with teams you belong to.
+
+
+ {teamItems.map((team) => (
+
+
+ {team.teamName}
+
+ {Object.keys(team.items).map((type) => {
+ if (!team.items[type]?.items?.length) return null;
+ return (
+
+
+ {readableType(type)}
+
+
+ {team.items[type].items.map((item) => (
+
+ ))}
+
+
+ );
+ })}
+ {!hasItems(team.items) && (
+
+ No items shared with this team yet.
+
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..e4b32fb75e4f00c1fd1db82ebf9b61e30eadb93e
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/index.jsx
@@ -0,0 +1,206 @@
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import { useEffect, useState } from "react";
+import CommunityHub from "@/models/communityHub";
+import ContextualSaveBar from "@/components/ContextualSaveBar";
+import showToast from "@/utils/toast";
+import { FullScreenLoader } from "@/components/Preloader";
+import paths from "@/utils/paths";
+import { Info } from "@phosphor-icons/react";
+import UserItems from "./UserItems";
+
+function useCommunityHubAuthentication() {
+ const [originalConnectionKey, setOriginalConnectionKey] = useState("");
+ const [hasChanges, setHasChanges] = useState(false);
+ const [connectionKey, setConnectionKey] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ async function resetChanges() {
+ setConnectionKey(originalConnectionKey);
+ setHasChanges(false);
+ }
+
+ async function onConnectionKeyChange(e) {
+ const newConnectionKey = e.target.value;
+ setConnectionKey(newConnectionKey);
+ setHasChanges(true);
+ }
+
+ async function updateConnectionKey() {
+ if (connectionKey === originalConnectionKey) return;
+ setLoading(true);
+ try {
+ const response = await CommunityHub.updateSettings({
+ hub_api_key: connectionKey,
+ });
+ if (!response.success)
+ return showToast("Failed to save API key", "error");
+ setHasChanges(false);
+ showToast("API key saved successfully", "success");
+ setOriginalConnectionKey(connectionKey);
+ } catch (error) {
+ console.error(error);
+ showToast("Failed to save API key", "error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function disconnectHub() {
+ setLoading(true);
+ try {
+ const response = await CommunityHub.updateSettings({
+ hub_api_key: "",
+ });
+ if (!response.success)
+ return showToast("Failed to disconnect from hub", "error");
+ setHasChanges(false);
+ showToast("Disconnected from AnythingLLM Community Hub", "success");
+ setOriginalConnectionKey("");
+ setConnectionKey("");
+ } catch (error) {
+ console.error(error);
+ showToast("Failed to disconnect from hub", "error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ const fetchData = async () => {
+ setLoading(true);
+ try {
+ const { connectionKey } = await CommunityHub.getSettings();
+ setOriginalConnectionKey(connectionKey || "");
+ setConnectionKey(connectionKey || "");
+ } catch (error) {
+ console.error("Error fetching data:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchData();
+ }, []);
+
+ return {
+ connectionKey,
+ originalConnectionKey,
+ loading,
+ onConnectionKeyChange,
+ updateConnectionKey,
+ hasChanges,
+ resetChanges,
+ disconnectHub,
+ };
+}
+
+export default function CommunityHubAuthentication() {
+ const {
+ connectionKey,
+ originalConnectionKey,
+ loading,
+ onConnectionKeyChange,
+ updateConnectionKey,
+ hasChanges,
+ resetChanges,
+ disconnectHub,
+ } = useCommunityHubAuthentication();
+ if (loading) return ;
+ return (
+
+
+
+
+
+
+
+
+ Your AnythingLLM Community Hub Account
+
+
+
+ Connecting your AnythingLLM Community Hub account allows you to
+ access your private AnythingLLM Community Hub items as well
+ as upload your own items to the AnythingLLM Community Hub.
+
+
+
+ {!connectionKey && (
+
+
+
+
+
+ Why connect my AnythingLLM Community Hub account?
+
+
+
+ Connecting your AnythingLLM Community Hub account allows you
+ to pull in your private items from the AnythingLLM
+ Community Hub as well as upload your own items to the
+ AnythingLLM Community Hub.
+
+
+
+ You do not need to connect your AnythingLLM Community Hub
+ account to pull in public items from the AnythingLLM
+ Community Hub.
+
+
+
+
+ )}
+
+ {/* API Key Section */}
+
+
+
+ AnythingLLM Hub API Key
+
+
+
+
+
+
+ {!!originalConnectionKey && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/useUserItems.js b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/useUserItems.js
new file mode 100644
index 0000000000000000000000000000000000000000..49274aaea29fefc9d9afebb586eca351ba572137
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Authentication/useUserItems.js
@@ -0,0 +1,40 @@
+import { useState, useEffect } from "react";
+import CommunityHub from "@/models/communityHub";
+
+const DEFAULT_USER_ITEMS = {
+ createdByMe: {
+ agentSkills: { items: [] },
+ systemPrompts: { items: [] },
+ slashCommands: { items: [] },
+ agentFlows: { items: [] },
+ },
+ teamItems: [],
+};
+
+export function useUserItems({ connectionKey }) {
+ const [loading, setLoading] = useState(true);
+ const [userItems, setUserItems] = useState(DEFAULT_USER_ITEMS);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ console.log("fetching user items", connectionKey);
+ if (!connectionKey) return;
+ setLoading(true);
+ try {
+ const { success, createdByMe, teamItems } =
+ await CommunityHub.fetchUserItems();
+ if (success) {
+ setUserItems({ createdByMe, teamItems });
+ }
+ } catch (error) {
+ console.error("Error fetching user items:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [connectionKey]);
+
+ return { loading, userItems };
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Completed/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Completed/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b7b6fff7a76d3cc730e49e4a9cb646cf8c40447f
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Completed/index.jsx
@@ -0,0 +1,46 @@
+import CommunityHubImportItemSteps from "..";
+import CTAButton from "@/components/lib/CTAButton";
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+
+export default function Completed({ settings, setSettings, setStep }) {
+ return (
+
+
+
+
+ Community Hub Item Imported
+
+
+
+ The "{settings.item.name}" {settings.item.itemType} has been
+ imported successfully! It is now available in your AnythingLLM
+ instance.
+
+ {settings.item.itemType === "agent-flow" && (
+
+ View "{settings.item.name}" in Agent Skills
+
+ )}
+
+ Any changes you make to this {settings.item.itemType} will not be
+ reflected in the community hub. You can now modify as needed.
+
+
+
{
+ setSettings({ item: null, itemId: null });
+ setStep(CommunityHubImportItemSteps.itemId.key);
+ }}
+ >
+ Import another item
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Introduction/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Introduction/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..2a9053bb87ce047f996cd77134cd8f5d20a61ff8
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/Introduction/index.jsx
@@ -0,0 +1,76 @@
+import CommunityHubImportItemSteps from "..";
+import CTAButton from "@/components/lib/CTAButton";
+import paths from "@/utils/paths";
+import showToast from "@/utils/toast";
+import { useState } from "react";
+
+export default function Introduction({ settings, setSettings, setStep }) {
+ const [itemId, setItemId] = useState(settings.itemId);
+ const handleContinue = () => {
+ if (!itemId) return showToast("Please enter an item ID", "error");
+ setSettings((prev) => ({ ...prev, itemId }));
+ setStep(CommunityHubImportItemSteps.itemId.next());
+ };
+
+ return (
+
+
+
+
+ Import an item from the community hub
+
+
+
+ The community hub is a place where you can find, share, and import
+ agent-skills, system prompts, slash commands, and more!
+
+
+ These items are created by the AnythingLLM team and community, and
+ are a great way to get started with AnythingLLM as well as extend
+ AnythingLLM in a way that is customized to your needs.
+
+
+ There are both private and public items in the
+ community hub. Private items are only visible to you, while public
+ items are visible to everyone.
+
+
+
+ If you are pulling in a private item, make sure it is{" "}
+ shared with a team you belong to, and you have added a{" "}
+
+ Connection Key.
+
+
+
+
+
+
+
+
+ Community Hub Item Import ID
+
+ setItemId(e.target.value)}
+ placeholder="allm-community-id:agent-skill:1234567890"
+ className="border-none bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ />
+
+
+
+
+ Continue with import →
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentFlow.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentFlow.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..39f8c344b97f17c6758e3eacccc7d72a7424e04e
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentFlow.jsx
@@ -0,0 +1,80 @@
+import CTAButton from "@/components/lib/CTAButton";
+import CommunityHubImportItemSteps from "../..";
+import showToast from "@/utils/toast";
+import paths from "@/utils/paths";
+import { CircleNotch } from "@phosphor-icons/react";
+import { useState } from "react";
+import AgentFlows from "@/models/agentFlows";
+import { safeJsonParse } from "@/utils/request";
+
+export default function AgentFlow({ item, setStep }) {
+ const flowInfo = safeJsonParse(item.flow, { steps: [] });
+ const [loading, setLoading] = useState(false);
+
+ async function importAgentFlow() {
+ try {
+ setLoading(true);
+ const { success, error, flow } = await AgentFlows.saveFlow(
+ item.name,
+ flowInfo
+ );
+ if (!success) throw new Error(error);
+ if (!!flow?.uuid) await AgentFlows.toggleFlow(flow.uuid, true); // Enable the flow automatically after import
+
+ showToast(`Agent flow imported successfully!`, "success");
+ setStep(CommunityHubImportItemSteps.completed.key);
+ } catch (e) {
+ console.error(e);
+ showToast(`Failed to import agent flow. ${e.message}`, "error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Agent flows allow you to create reusable sequences of actions that can
+ be triggered by your agent.
+
+
+
Flow Details:
+
Description: {item.description}
+
Steps ({flowInfo.steps.length}):
+
+ {flowInfo.steps.map((step, index) => (
+ {step.type}
+ ))}
+
+
+
+
+ {loading ? : null}
+ {loading ? "Importing..." : "Import agent flow"}
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentSkill.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentSkill.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c5f10a65a8115a41017b4da720256e7edc1c2207
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/AgentSkill.jsx
@@ -0,0 +1,190 @@
+import CTAButton from "@/components/lib/CTAButton";
+import CommunityHubImportItemSteps from "../..";
+import showToast from "@/utils/toast";
+import paths from "@/utils/paths";
+import {
+ CaretLeft,
+ CaretRight,
+ CircleNotch,
+ Warning,
+} from "@phosphor-icons/react";
+import { useEffect, useState } from "react";
+import renderMarkdown from "@/utils/chat/markdown";
+import DOMPurify from "dompurify";
+import CommunityHub from "@/models/communityHub";
+import { setEventDelegatorForCodeSnippets } from "@/components/WorkspaceChat";
+
+export default function AgentSkill({ item, settings, setStep }) {
+ const [loading, setLoading] = useState(false);
+ async function importAgentSkill() {
+ try {
+ setLoading(true);
+ const { error } = await CommunityHub.importBundleItem(settings.itemId);
+ if (error) throw new Error(error);
+ showToast(`Agent skill imported successfully!`, "success");
+ setStep(CommunityHubImportItemSteps.completed.key);
+ } catch (e) {
+ console.error(e);
+ showToast(`Failed to import agent skill. ${e.message}`, "error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ setEventDelegatorForCodeSnippets();
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {" "}
+ Only import agent skills you trust{" "}
+
+
+
+ Agent skills can execute code on your AnythingLLM instance, so only
+ import agent skills from sources you trust. You should also review
+ the code before importing. If you are unsure about what a skill does
+ - don't import it!
+
+
+
+
+
+
+ Review Agent Skill "{item.name}"
+
+ {item.creatorUsername && (
+
+ Created by{" "}
+
+ @{item.creatorUsername}
+
+
+ )}
+
+ {item.verified ? (
+
Verified code
+ ) : (
+
+ This skill is not verified.
+
+ )}
+
+ Learn more →
+
+
+
+
+
+ Agent skills unlock new capabilities for your AnythingLLM workspace
+ via{" "}
+
+ @agent
+ {" "}
+ skills that can do specific tasks when invoked.
+
+
+
+
+ {loading ? : null}
+ {loading ? "Importing..." : "Import agent skill"}
+
+
+ );
+}
+
+function FileReview({ item }) {
+ const files = item.manifest.files || [];
+ const [index, setIndex] = useState(0);
+ const [file, setFile] = useState(files[index]);
+ function handlePrevious() {
+ if (index > 0) setIndex(index - 1);
+ }
+
+ function handleNext() {
+ if (index < files.length - 1) setIndex(index + 1);
+ }
+
+ function fileMarkup(file) {
+ const extension = file.name.split(".").pop();
+ switch (extension) {
+ case "js":
+ return "javascript";
+ case "json":
+ return "json";
+ case "md":
+ return "markdown";
+ default:
+ return "text";
+ }
+ }
+
+ useEffect(() => {
+ if (files.length > 0) setFile(files?.[index] || files[0]);
+ }, [index]);
+
+ if (!file) return null;
+ return (
+
+
+
+
+
+
+
+ {file.name} ({index + 1} of {files.length} files)
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SlashCommand.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SlashCommand.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..aa33a777bd1208aba66555d11126e285463320be
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SlashCommand.jsx
@@ -0,0 +1,81 @@
+import CTAButton from "@/components/lib/CTAButton";
+import CommunityHubImportItemSteps from "../..";
+import showToast from "@/utils/toast";
+import paths from "@/utils/paths";
+import CommunityHub from "@/models/communityHub";
+
+export default function SlashCommand({ item, setStep }) {
+ async function handleSubmit() {
+ try {
+ const { error } = await CommunityHub.applyItem(item.importId);
+ if (error) throw new Error(error);
+ showToast(
+ `Slash command ${item.command} imported successfully!`,
+ "success"
+ );
+ setStep(CommunityHubImportItemSteps.completed.key);
+ } catch (e) {
+ console.error(e);
+ showToast(`Failed to import slash command. ${e.message}`, "error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Slash commands are used to prefill information into a prompt while
+ chatting with a AnythingLLM workspace.
+
+
+ The slash command will be available during chatting by simply invoking
+ it with{" "}
+
+ {item.command}
+ {" "}
+ like you would any other command.
+
+
+
+
+
+ Import slash command
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SystemPrompt.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SystemPrompt.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3e1bcc78e29a9099a633b816f873a91a0e8493a6
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/SystemPrompt.jsx
@@ -0,0 +1,106 @@
+import CTAButton from "@/components/lib/CTAButton";
+import CommunityHubImportItemSteps from "../..";
+import { useEffect, useState } from "react";
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+import paths from "@/utils/paths";
+import CommunityHub from "@/models/communityHub";
+
+export default function SystemPrompt({ item, setStep }) {
+ const [destinationWorkspaceSlug, setDestinationWorkspaceSlug] =
+ useState(null);
+ const [workspaces, setWorkspaces] = useState([]);
+ useEffect(() => {
+ async function getWorkspaces() {
+ const workspaces = await Workspace.all();
+ setWorkspaces(workspaces);
+ setDestinationWorkspaceSlug(workspaces[0].slug);
+ }
+ getWorkspaces();
+ }, []);
+
+ async function handleSubmit() {
+ showToast("Applying system prompt to workspace...", "info");
+ const { error } = await CommunityHub.applyItem(item.importId, {
+ workspaceSlug: destinationWorkspaceSlug,
+ });
+ if (error) {
+ return showToast(`Failed to apply system prompt. ${error}`, "error", {
+ clear: true,
+ });
+ }
+
+ showToast("System prompt applied to workspace.", "success", {
+ clear: true,
+ });
+ setStep(CommunityHubImportItemSteps.completed.key);
+ }
+
+ return (
+
+
+
+
+ System prompts are used to guide the behavior of the AI agents and can
+ be applied to any existing workspace.
+
+
+
+
+ Provided system prompt:
+
+
+
+
+
+
+ Apply to Workspace
+
+ setDestinationWorkspaceSlug(e.target.value)}
+ className="border-none bg-theme-settings-input-bg border-gray-500 text-white text-sm rounded-lg block w-full p-2.5"
+ >
+
+ {workspaces.map((workspace) => (
+
+ {workspace.name}
+
+ ))}
+
+
+
+
+ {destinationWorkspaceSlug && (
+
+ Apply system prompt to workspace
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/Unknown.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/Unknown.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f96d57619a4cad7319434ded785a298a6fd5648d
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/Unknown.jsx
@@ -0,0 +1,39 @@
+import CTAButton from "@/components/lib/CTAButton";
+import CommunityHubImportItemSteps from "../..";
+import { Warning } from "@phosphor-icons/react";
+
+export default function UnknownItem({ item, setSettings, setStep }) {
+ return (
+
+
+
+
+ Unsupported item
+
+
+
+
+ We found an item in the community hub, but we don't know what it is or
+ it is not yet supported for import into AnythingLLM.
+
+
+ The item ID is: {item.id}
+
+ The item type is: {item.itemType}
+
+
+ Please contact support via email if you need help importing this item.
+
+
+
{
+ setSettings({ itemId: null, item: null });
+ setStep(CommunityHubImportItemSteps.itemId.key);
+ }}
+ >
+ Try another item
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/index.js b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..4362043c16f21e125b687fd3aba49a5a3bdadd6d
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/HubItem/index.js
@@ -0,0 +1,15 @@
+import SystemPrompt from "./SystemPrompt";
+import SlashCommand from "./SlashCommand";
+import UnknownItem from "./Unknown";
+import AgentSkill from "./AgentSkill";
+import AgentFlow from "./AgentFlow";
+
+const HubItemComponent = {
+ "agent-skill": AgentSkill,
+ "system-prompt": SystemPrompt,
+ "slash-command": SlashCommand,
+ "agent-flow": AgentFlow,
+ unknown: UnknownItem,
+};
+
+export default HubItemComponent;
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..040df7439367c84b28199b8ff2eb12f91aadfac3
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/PullAndReview/index.jsx
@@ -0,0 +1,86 @@
+import CommunityHub from "@/models/communityHub";
+import CommunityHubImportItemSteps from "..";
+import CTAButton from "@/components/lib/CTAButton";
+import { useEffect, useState } from "react";
+import HubItemComponent from "./HubItem";
+
+function useGetCommunityHubItem({ importId, updateSettings }) {
+ const [item, setItem] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ async function fetchItem() {
+ if (!importId) return;
+ setLoading(true);
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ const { error, item } = await CommunityHub.getItemFromImportId(importId);
+ if (error) setError(error);
+ setItem(item);
+ updateSettings((prev) => ({ ...prev, item }));
+ setLoading(false);
+ }
+ fetchItem();
+ }, [importId]);
+
+ return { item, loading, error };
+}
+
+export default function PullAndReview({ settings, setSettings, setStep }) {
+ const { item, loading, error } = useGetCommunityHubItem({
+ importId: settings.itemId,
+ updateSettings: setSettings,
+ });
+ const ItemComponent =
+ HubItemComponent[item?.itemType] || HubItemComponent["unknown"];
+
+ return (
+
+
+
+
+ Review item
+
+
+ {loading && (
+
+
+
+ Pulling item details from community hub...
+
+
+
+ )}
+ {!loading && error && (
+ <>
+
+
+ An error occurred while fetching the item. Please try again
+ later.
+
+
{error}
+
+
{
+ setSettings({ itemId: null, item: null });
+ setStep(CommunityHubImportItemSteps.itemId.key);
+ }}
+ >
+ Try another item
+
+ >
+ )}
+ {!loading && !error && item && (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..97b33f7f628f9e157c7eb093e88acc27c8f792d2
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/Steps/index.jsx
@@ -0,0 +1,77 @@
+import { isMobile } from "react-device-detect";
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import Introduction from "./Introduction";
+import PullAndReview from "./PullAndReview";
+import Completed from "./Completed";
+import useQuery from "@/hooks/useQuery";
+
+const CommunityHubImportItemSteps = {
+ itemId: {
+ key: "itemId",
+ name: "1. Paste in Item ID",
+ next: () => "validation",
+ component: ({ settings, setSettings, setStep }) => (
+
+ ),
+ },
+ validation: {
+ key: "validation",
+ name: "2. Review item",
+ next: () => "completed",
+ component: ({ settings, setSettings, setStep }) => (
+
+ ),
+ },
+ completed: {
+ key: "completed",
+ name: "3. Completed",
+ component: ({ settings, setSettings, setStep }) => (
+
+ ),
+ },
+};
+
+export function CommunityHubImportItemLayout({ setStep, children }) {
+ const query = useQuery();
+ const [settings, setSettings] = useState({
+ itemId: null,
+ item: null,
+ });
+
+ useEffect(() => {
+ function autoForward() {
+ if (query.get("id")) {
+ setSettings({ itemId: query.get("id") });
+ setStep(CommunityHubImportItemSteps.itemId.next());
+ }
+ }
+ autoForward();
+ }, []);
+
+ return (
+
+
+
+ {children(settings, setSettings, setStep)}
+
+
+ );
+}
+
+export default CommunityHubImportItemSteps;
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b79afbf6d82bee0b462503ce569f5c22435426bc
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/ImportItem/index.jsx
@@ -0,0 +1,106 @@
+import React, { useState } from "react";
+import { isMobile } from "react-device-detect";
+import CommunityHubImportItemSteps, {
+ CommunityHubImportItemLayout,
+} from "./Steps";
+
+function SideBarSelection({ setStep, currentStep }) {
+ const currentIndex = Object.keys(CommunityHubImportItemSteps).indexOf(
+ currentStep
+ );
+ return (
+
+ {Object.entries(CommunityHubImportItemSteps).map(
+ ([stepKey, props], index) => {
+ const isSelected = currentStep === stepKey;
+ const isLast =
+ index === Object.keys(CommunityHubImportItemSteps).length - 1;
+ const isDone =
+ currentIndex ===
+ Object.keys(CommunityHubImportItemSteps).length - 1 ||
+ index < currentIndex;
+ return (
+
+ {isDone || isSelected ? (
+
setStep(stepKey)}
+ className="border-none hover:underline text-sm font-medium text-theme-text-primary"
+ >
+ {props.name}
+
+ ) : (
+
+ {props.name}
+
+ )}
+
+ {isDone ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ }
+ )}
+
+ );
+}
+
+export default function CommunityHubImportItemFlow() {
+ const [step, setStep] = useState("itemId");
+
+ const StepPage = CommunityHubImportItemSteps.hasOwnProperty(step)
+ ? CommunityHubImportItemSteps[step]
+ : CommunityHubImportItemSteps.itemId;
+
+ return (
+
+ {(settings, setSettings, setStep) => (
+
+
+
+
+ Import a Community Item
+
+
+
+ Import items from the AnythingLLM Community Hub to enhance your
+ instance with community-created prompts, skills, and commands.
+
+
+
+
+
+
+
+
+ {StepPage.component({ settings, setSettings, setStep })}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentFlow.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentFlow.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d2c73f5ec09dabefe390e0e7851739d1e1fc4fad
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentFlow.jsx
@@ -0,0 +1,39 @@
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+import { VisibilityIcon } from "./generic";
+
+export default function AgentFlowHubCard({ item }) {
+ const flow = JSON.parse(item.flow);
+ return (
+
+
+
+
{item.description}
+
+ Steps ({flow.steps.length}):
+
+
+
+ {flow.steps.map((step, index) => (
+ {step.type}
+ ))}
+
+
+
+
+
+ Import →
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentSkill.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentSkill.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cac561be607a4b83dfa96c330a7ec5fc47b3f0f9
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/agentSkill.jsx
@@ -0,0 +1,45 @@
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+import pluralize from "pluralize";
+import { VisibilityIcon } from "./generic";
+
+export default function AgentSkillHubCard({ item }) {
+ return (
+ <>
+
+
+
+
{item.description}
+
+
+ {item.verified ? (
+ Verified
+ ) : (
+ Unverified
+ )}{" "}
+ Skill
+
+
+ {item.manifest.files?.length || 0}{" "}
+ {pluralize("file", item.manifest.files?.length || 0)} found
+
+
+
+
+ Import →
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/generic.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/generic.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3b62dd077e541367870a84a09d08d4894348a5dc
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/generic.jsx
@@ -0,0 +1,45 @@
+import paths from "@/utils/paths";
+import { Eye, LockSimple } from "@phosphor-icons/react";
+import { Link } from "react-router-dom";
+import { Tooltip } from "react-tooltip";
+
+export default function GenericHubCard({ item }) {
+ return (
+
+
{item.name}
+
{item.description}
+
+
+ Import →
+
+
+
+ );
+}
+
+export function VisibilityIcon({ visibility = "public" }) {
+ const Icon = visibility === "private" ? LockSimple : Eye;
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..371343a68a8be4b056fa5371d9fccd1a7b3b0b26
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/index.jsx
@@ -0,0 +1,20 @@
+import GenericHubCard from "./generic";
+import SystemPromptHubCard from "./systemPrompt";
+import SlashCommandHubCard from "./slashCommand";
+import AgentSkillHubCard from "./agentSkill";
+import AgentFlowHubCard from "./agentFlow";
+
+export default function HubItemCard({ type, item }) {
+ switch (type) {
+ case "systemPrompts":
+ return ;
+ case "slashCommands":
+ return ;
+ case "agentSkills":
+ return ;
+ case "agentFlows":
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/slashCommand.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/slashCommand.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7ed6781e74f943ef5b189d7c6ab63320eeed2714
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/slashCommand.jsx
@@ -0,0 +1,45 @@
+import truncate from "truncate";
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+import { VisibilityIcon } from "./generic";
+
+export default function SlashCommandHubCard({ item }) {
+ return (
+ <>
+
+
+
+
{item.description}
+
+ Command
+
+
+ {item.command}
+
+
+
+ Prompt
+
+
+ {truncate(item.prompt, 90)}
+
+
+
+
+ Import →
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/systemPrompt.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/systemPrompt.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3c929a309c2577dec651cedcd135075f998c2c6a
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/HubItemCard/systemPrompt.jsx
@@ -0,0 +1,38 @@
+import truncate from "truncate";
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+import { VisibilityIcon } from "./generic";
+
+export default function SystemPromptHubCard({ item }) {
+ return (
+ <>
+
+
+
+
{item.description}
+
+ Prompt
+
+
+ {truncate(item.prompt, 90)}
+
+
+
+
+ Import →
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..2c1ff58931812593f7e0676e94498848f3397f7e
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/HubItems/index.jsx
@@ -0,0 +1,135 @@
+import { useEffect, useState } from "react";
+import CommunityHub from "@/models/communityHub";
+import paths from "@/utils/paths";
+import HubItemCard from "./HubItemCard";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { readableType, typeToPath } from "../../utils";
+
+const DEFAULT_EXPLORE_ITEMS = {
+ agentSkills: { items: [], hasMore: false, totalCount: 0 },
+ systemPrompts: { items: [], hasMore: false, totalCount: 0 },
+ slashCommands: { items: [], hasMore: false, totalCount: 0 },
+};
+
+function useCommunityHubExploreItems() {
+ const [loading, setLoading] = useState(true);
+ const [exploreItems, setExploreItems] = useState(DEFAULT_EXPLORE_ITEMS);
+ useEffect(() => {
+ const fetchData = async () => {
+ setLoading(true);
+ try {
+ const { success, result } = await CommunityHub.fetchExploreItems();
+ if (success) setExploreItems(result || DEFAULT_EXPLORE_ITEMS);
+ } catch (error) {
+ console.error("Error fetching data:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ return { loading, exploreItems };
+}
+
+export default function HubItems() {
+ const { loading, exploreItems } = useCommunityHubExploreItems();
+ return (
+
+
+
+ Recently Added on AnythingLLM Community Hub
+
+
+ Explore the latest additions to the AnythingLLM Community Hub
+
+
+
+
+ );
+}
+
+function HubCategory({ loading, exploreItems }) {
+ if (loading) return ;
+ return (
+
+ {Object.keys(exploreItems).map((type) => {
+ const path = typeToPath(type);
+ if (exploreItems[type].items.length === 0) return null;
+ return (
+
+
+
+ {exploreItems[type].items.map((item) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+export function HubItemCardSkeleton() {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/Trending/index.jsx b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..356029595ab46697720cff22c9d1e2c2cd9b7c63
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/Trending/index.jsx
@@ -0,0 +1,29 @@
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import HubItems from "./HubItems";
+
+export default function CommunityHub() {
+ return (
+
+
+
+
+
+
+
+ Share and collaborate with the AnythingLLM community.
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/CommunityHub/utils.js b/frontend/src/pages/GeneralSettings/CommunityHub/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..782dae2f55dfd89bf0e551f796c2383c5287769a
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/CommunityHub/utils.js
@@ -0,0 +1,43 @@
+/**
+ * Convert a type to a readable string for the community hub.
+ * @param {("agentSkills" | "agentSkill" | "systemPrompts" | "systemPrompt" | "slashCommands" | "slashCommand" | "agentFlows" | "agentFlow")} type
+ * @returns {string}
+ */
+export function readableType(type) {
+ switch (type) {
+ case "agentSkills":
+ case "agentSkill":
+ return "Agent Skills";
+ case "systemPrompt":
+ case "systemPrompts":
+ return "System Prompts";
+ case "slashCommand":
+ case "slashCommands":
+ return "Slash Commands";
+ case "agentFlows":
+ case "agentFlow":
+ return "Agent Flows";
+ }
+}
+
+/**
+ * Convert a type to a path for the community hub.
+ * @param {("agentSkill" | "agentSkills" | "systemPrompt" | "systemPrompts" | "slashCommand" | "slashCommands" | "agentFlow" | "agentFlows")} type
+ * @returns {string}
+ */
+export function typeToPath(type) {
+ switch (type) {
+ case "agentSkill":
+ case "agentSkills":
+ return "agent-skills";
+ case "systemPrompt":
+ case "systemPrompts":
+ return "system-prompts";
+ case "slashCommand":
+ case "slashCommands":
+ return "slash-commands";
+ case "agentFlow":
+ case "agentFlows":
+ return "agent-flows";
+ }
+}
diff --git a/frontend/src/pages/GeneralSettings/EmbeddingPreference/index.jsx b/frontend/src/pages/GeneralSettings/EmbeddingPreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..de27acb80a2e26be6213711adba11994c6080e3e
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/EmbeddingPreference/index.jsx
@@ -0,0 +1,380 @@
+import React, { useEffect, useState, useRef } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import OpenAiLogo from "@/media/llmprovider/openai.png";
+import AzureOpenAiLogo from "@/media/llmprovider/azure.png";
+import GeminiAiLogo from "@/media/llmprovider/gemini.png";
+import LocalAiLogo from "@/media/llmprovider/localai.png";
+import OllamaLogo from "@/media/llmprovider/ollama.png";
+import LMStudioLogo from "@/media/llmprovider/lmstudio.png";
+import CohereLogo from "@/media/llmprovider/cohere.png";
+import VoyageAiLogo from "@/media/embeddingprovider/voyageai.png";
+import LiteLLMLogo from "@/media/llmprovider/litellm.png";
+import GenericOpenAiLogo from "@/media/llmprovider/generic-openai.png";
+import MistralAiLogo from "@/media/llmprovider/mistral.jpeg";
+
+import PreLoader from "@/components/Preloader";
+import ChangeWarningModal from "@/components/ChangeWarning";
+import OpenAiOptions from "@/components/EmbeddingSelection/OpenAiOptions";
+import AzureAiOptions from "@/components/EmbeddingSelection/AzureAiOptions";
+import GeminiOptions from "@/components/EmbeddingSelection/GeminiOptions";
+import LocalAiOptions from "@/components/EmbeddingSelection/LocalAiOptions";
+import NativeEmbeddingOptions from "@/components/EmbeddingSelection/NativeEmbeddingOptions";
+import OllamaEmbeddingOptions from "@/components/EmbeddingSelection/OllamaOptions";
+import LMStudioEmbeddingOptions from "@/components/EmbeddingSelection/LMStudioOptions";
+import CohereEmbeddingOptions from "@/components/EmbeddingSelection/CohereOptions";
+import VoyageAiOptions from "@/components/EmbeddingSelection/VoyageAiOptions";
+import LiteLLMOptions from "@/components/EmbeddingSelection/LiteLLMOptions";
+import GenericOpenAiEmbeddingOptions from "@/components/EmbeddingSelection/GenericOpenAiOptions";
+
+import EmbedderItem from "@/components/EmbeddingSelection/EmbedderItem";
+import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import CTAButton from "@/components/lib/CTAButton";
+import { useTranslation } from "react-i18next";
+import MistralAiOptions from "@/components/EmbeddingSelection/MistralAiOptions";
+
+const EMBEDDERS = [
+ {
+ name: "AnythingLLM Embedder",
+ value: "native",
+ logo: AnythingLLMIcon,
+ options: (settings) => ,
+ description:
+ "Use the built-in embedding provider for AnythingLLM. Zero setup!",
+ },
+ {
+ name: "OpenAI",
+ value: "openai",
+ logo: OpenAiLogo,
+ options: (settings) => ,
+ description: "The standard option for most non-commercial use.",
+ },
+ {
+ name: "Azure OpenAI",
+ value: "azure",
+ logo: AzureOpenAiLogo,
+ options: (settings) => ,
+ description: "The enterprise option of OpenAI hosted on Azure services.",
+ },
+ {
+ name: "Gemini",
+ value: "gemini",
+ logo: GeminiAiLogo,
+ options: (settings) => ,
+ description: "Run powerful embedding models from Google AI.",
+ },
+ {
+ name: "Local AI",
+ value: "localai",
+ logo: LocalAiLogo,
+ options: (settings) => ,
+ description: "Run embedding models locally on your own machine.",
+ },
+ {
+ name: "Ollama",
+ value: "ollama",
+ logo: OllamaLogo,
+ options: (settings) => ,
+ description: "Run embedding models locally on your own machine.",
+ },
+ {
+ name: "LM Studio",
+ value: "lmstudio",
+ logo: LMStudioLogo,
+ options: (settings) => ,
+ description:
+ "Discover, download, and run thousands of cutting edge LLMs in a few clicks.",
+ },
+ {
+ name: "Cohere",
+ value: "cohere",
+ logo: CohereLogo,
+ options: (settings) => ,
+ description: "Run powerful embedding models from Cohere.",
+ },
+ {
+ name: "Voyage AI",
+ value: "voyageai",
+ logo: VoyageAiLogo,
+ options: (settings) => ,
+ description: "Run powerful embedding models from Voyage AI.",
+ },
+ {
+ name: "LiteLLM",
+ value: "litellm",
+ logo: LiteLLMLogo,
+ options: (settings) => ,
+ description: "Run powerful embedding models from LiteLLM.",
+ },
+ {
+ name: "Mistral AI",
+ value: "mistral",
+ logo: MistralAiLogo,
+ options: (settings) => ,
+ description: "Run powerful embedding models from Mistral AI.",
+ },
+ {
+ name: "Generic OpenAI",
+ value: "generic-openai",
+ logo: GenericOpenAiLogo,
+ options: (settings) => (
+
+ ),
+ description: "Run embedding models from any OpenAI compatible API service.",
+ },
+];
+
+export default function GeneralEmbeddingPreference() {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [hasEmbeddings, setHasEmbeddings] = useState(false);
+ const [hasCachedEmbeddings, setHasCachedEmbeddings] = useState(false);
+ const [settings, setSettings] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredEmbedders, setFilteredEmbedders] = useState([]);
+ const [selectedEmbedder, setSelectedEmbedder] = useState(null);
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+ const { isOpen, openModal, closeModal } = useModal();
+ const { t } = useTranslation();
+
+ function embedderModelChanged(formEl) {
+ try {
+ const newModel = new FormData(formEl).get("EmbeddingModelPref") ?? null;
+ if (newModel === null) return false;
+ return settings?.EmbeddingModelPref !== newModel;
+ } catch (error) {
+ console.error(error);
+ }
+ return false;
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (
+ (selectedEmbedder !== settings?.EmbeddingEngine ||
+ embedderModelChanged(e.target)) &&
+ hasChanges &&
+ (hasEmbeddings || hasCachedEmbeddings)
+ ) {
+ openModal();
+ } else {
+ await handleSaveSettings();
+ }
+ };
+
+ const handleSaveSettings = async () => {
+ setSaving(true);
+ const form = document.getElementById("embedding-form");
+ const settingsData = {};
+ const formData = new FormData(form);
+ settingsData.EmbeddingEngine = selectedEmbedder;
+ for (var [key, value] of formData.entries()) settingsData[key] = value;
+
+ const { error } = await System.updateSystem(settingsData);
+ if (error) {
+ showToast(`Failed to save embedding settings: ${error}`, "error");
+ setHasChanges(true);
+ } else {
+ showToast("Embedding preferences saved successfully.", "success");
+ setHasChanges(false);
+ }
+ setSaving(false);
+ closeModal();
+ };
+
+ const updateChoice = (selection) => {
+ setSearchQuery("");
+ setSelectedEmbedder(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ };
+
+ const handleXButton = () => {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ };
+
+ useEffect(() => {
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ setSelectedEmbedder(_settings?.EmbeddingEngine || "native");
+ setHasEmbeddings(_settings?.HasExistingEmbeddings || false);
+ setHasCachedEmbeddings(_settings?.HasCachedEmbeddings || false);
+ setLoading(false);
+ }
+ fetchKeys();
+ }, []);
+
+ useEffect(() => {
+ const filtered = EMBEDDERS.filter((embedder) =>
+ embedder.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredEmbedders(filtered);
+ }, [searchQuery, selectedEmbedder]);
+
+ const selectedEmbedderObject = EMBEDDERS.find(
+ (embedder) => embedder.value === selectedEmbedder
+ );
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+ {t("embedding.title")}
+
+
+
+ {t("embedding.desc-start")}
+
+ {t("embedding.desc-end")}
+
+
+
+ {hasChanges && (
+ handleSubmit()}
+ className="mt-3 mr-0 -mb-14 z-10"
+ >
+ {saving ? t("common.saving") : t("common.save")}
+
+ )}
+
+
+ {t("embedding.provider.title")}
+
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredEmbedders.map((embedder) => (
+ updateChoice(embedder.value)}
+ />
+ ))}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedEmbedderObject.name}
+
+
+ {selectedEmbedderObject.description}
+
+
+
+
+
+ )}
+
+
setHasChanges(true)}
+ className="mt-4 flex flex-col gap-y-1"
+ >
+ {selectedEmbedder &&
+ EMBEDDERS.find(
+ (embedder) => embedder.value === selectedEmbedder
+ )?.options(settings)}
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/EmbeddingTextSplitterPreference/index.jsx b/frontend/src/pages/GeneralSettings/EmbeddingTextSplitterPreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..55d04133bba9f07924821f5cc7466d180f853786
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/EmbeddingTextSplitterPreference/index.jsx
@@ -0,0 +1,199 @@
+import React, { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import PreLoader from "@/components/Preloader";
+import CTAButton from "@/components/lib/CTAButton";
+import Admin from "@/models/admin";
+import showToast from "@/utils/toast";
+import { numberWithCommas } from "@/utils/numbers";
+import { useTranslation } from "react-i18next";
+import { useModal } from "@/hooks/useModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import ChangeWarningModal from "@/components/ChangeWarning";
+
+function isNullOrNaN(value) {
+ if (value === null) return true;
+ return isNaN(value);
+}
+
+export default function EmbeddingTextSplitterPreference() {
+ const [settings, setSettings] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const { isOpen, openModal, closeModal } = useModal();
+ const { t } = useTranslation();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = new FormData(e.target);
+
+ if (
+ Number(form.get("text_splitter_chunk_overlap")) >=
+ Number(form.get("text_splitter_chunk_size"))
+ ) {
+ showToast(
+ "Chunk overlap cannot be larger or equal to chunk size.",
+ "error"
+ );
+ return;
+ }
+
+ openModal();
+ };
+
+ const handleSaveSettings = async () => {
+ setSaving(true);
+ try {
+ const form = new FormData(
+ document.getElementById("text-splitter-chunking-form")
+ );
+ await Admin.updateSystemPreferences({
+ text_splitter_chunk_size: isNullOrNaN(
+ form.get("text_splitter_chunk_size")
+ )
+ ? 1000
+ : Number(form.get("text_splitter_chunk_size")),
+ text_splitter_chunk_overlap: isNullOrNaN(
+ form.get("text_splitter_chunk_overlap")
+ )
+ ? 1000
+ : Number(form.get("text_splitter_chunk_overlap")),
+ });
+ setHasChanges(false);
+ closeModal();
+ showToast("Text chunking strategy settings saved.", "success");
+ } catch (error) {
+ showToast("Failed to save text chunking strategy settings.", "error");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ useEffect(() => {
+ async function fetchSettings() {
+ const _settings = (await Admin.systemPreferences())?.settings;
+ setSettings(_settings ?? {});
+ setLoading(false);
+ }
+ fetchSettings();
+ }, []);
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+
+
setHasChanges(true)}
+ className="flex w-full"
+ id="text-splitter-chunking-form"
+ >
+
+
+
+
+ {t("text.title")}
+
+
+
+ {t("text.desc-start")}
+ {t("text.desc-end")}
+
+
+
+ {hasChanges && (
+
+ {saving ? t("common.saving") : t("common.save")}
+
+ )}
+
+
+
+
+
+
+ {t("text.size.title")}
+
+
+ {t("text.size.description")}
+
+
+
e?.currentTarget?.blur()}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ placeholder="maximum length of vectorized text"
+ defaultValue={
+ isNullOrNaN(settings?.text_splitter_chunk_size)
+ ? 1000
+ : Number(settings?.text_splitter_chunk_size)
+ }
+ required={true}
+ autoComplete="off"
+ />
+
+ {t("text.size.recommend")}{" "}
+ {numberWithCommas(settings?.max_embed_chunk_size || 1000)}.
+
+
+
+
+
+
+
+
+ {t("text.overlap.title")}
+
+
+ {t("text.overlap.description")}
+
+
+
e?.currentTarget?.blur()}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ placeholder="maximum length of vectorized text"
+ defaultValue={
+ isNullOrNaN(settings?.text_splitter_chunk_overlap)
+ ? 20
+ : Number(settings?.text_splitter_chunk_overlap)
+ }
+ required={true}
+ autoComplete="off"
+ />
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/LLMPreference/index.jsx b/frontend/src/pages/GeneralSettings/LLMPreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..afadca6ac09dee9cca48c1a6f949dcced6f63274
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/LLMPreference/index.jsx
@@ -0,0 +1,546 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import OpenAiLogo from "@/media/llmprovider/openai.png";
+import GenericOpenAiLogo from "@/media/llmprovider/generic-openai.png";
+import AzureOpenAiLogo from "@/media/llmprovider/azure.png";
+import AnthropicLogo from "@/media/llmprovider/anthropic.png";
+import GeminiLogo from "@/media/llmprovider/gemini.png";
+import OllamaLogo from "@/media/llmprovider/ollama.png";
+import NovitaLogo from "@/media/llmprovider/novita.png";
+import LMStudioLogo from "@/media/llmprovider/lmstudio.png";
+import LocalAiLogo from "@/media/llmprovider/localai.png";
+import TogetherAILogo from "@/media/llmprovider/togetherai.png";
+import FireworksAILogo from "@/media/llmprovider/fireworksai.jpeg";
+import MistralLogo from "@/media/llmprovider/mistral.jpeg";
+import HuggingFaceLogo from "@/media/llmprovider/huggingface.png";
+import PerplexityLogo from "@/media/llmprovider/perplexity.png";
+import OpenRouterLogo from "@/media/llmprovider/openrouter.jpeg";
+import GroqLogo from "@/media/llmprovider/groq.png";
+import KoboldCPPLogo from "@/media/llmprovider/koboldcpp.png";
+import TextGenWebUILogo from "@/media/llmprovider/text-generation-webui.png";
+import CohereLogo from "@/media/llmprovider/cohere.png";
+import LiteLLMLogo from "@/media/llmprovider/litellm.png";
+import AWSBedrockLogo from "@/media/llmprovider/bedrock.png";
+import DeepSeekLogo from "@/media/llmprovider/deepseek.png";
+import APIPieLogo from "@/media/llmprovider/apipie.png";
+import XAILogo from "@/media/llmprovider/xai.png";
+import NvidiaNimLogo from "@/media/llmprovider/nvidia-nim.png";
+import PPIOLogo from "@/media/llmprovider/ppio.png";
+import DellProAiStudioLogo from "@/media/llmprovider/dpais.png";
+import MoonshotAiLogo from "@/media/llmprovider/moonshotai.png";
+import CometApiLogo from "@/media/llmprovider/cometapi.png";
+
+import PreLoader from "@/components/Preloader";
+import OpenAiOptions from "@/components/LLMSelection/OpenAiOptions";
+import GenericOpenAiOptions from "@/components/LLMSelection/GenericOpenAiOptions";
+import AzureAiOptions from "@/components/LLMSelection/AzureAiOptions";
+import AnthropicAiOptions from "@/components/LLMSelection/AnthropicAiOptions";
+import LMStudioOptions from "@/components/LLMSelection/LMStudioOptions";
+import LocalAiOptions from "@/components/LLMSelection/LocalAiOptions";
+import GeminiLLMOptions from "@/components/LLMSelection/GeminiLLMOptions";
+import OllamaLLMOptions from "@/components/LLMSelection/OllamaLLMOptions";
+import NovitaLLMOptions from "@/components/LLMSelection/NovitaLLMOptions";
+import CometApiLLMOptions from "@/components/LLMSelection/CometApiLLMOptions";
+import TogetherAiOptions from "@/components/LLMSelection/TogetherAiOptions";
+import FireworksAiOptions from "@/components/LLMSelection/FireworksAiOptions";
+import MistralOptions from "@/components/LLMSelection/MistralOptions";
+import HuggingFaceOptions from "@/components/LLMSelection/HuggingFaceOptions";
+import PerplexityOptions from "@/components/LLMSelection/PerplexityOptions";
+import OpenRouterOptions from "@/components/LLMSelection/OpenRouterOptions";
+import GroqAiOptions from "@/components/LLMSelection/GroqAiOptions";
+import CohereAiOptions from "@/components/LLMSelection/CohereAiOptions";
+import KoboldCPPOptions from "@/components/LLMSelection/KoboldCPPOptions";
+import TextGenWebUIOptions from "@/components/LLMSelection/TextGenWebUIOptions";
+import LiteLLMOptions from "@/components/LLMSelection/LiteLLMOptions";
+import AWSBedrockLLMOptions from "@/components/LLMSelection/AwsBedrockLLMOptions";
+import DeepSeekOptions from "@/components/LLMSelection/DeepSeekOptions";
+import ApiPieLLMOptions from "@/components/LLMSelection/ApiPieOptions";
+import XAILLMOptions from "@/components/LLMSelection/XAiLLMOptions";
+import NvidiaNimOptions from "@/components/LLMSelection/NvidiaNimOptions";
+import PPIOLLMOptions from "@/components/LLMSelection/PPIOLLMOptions";
+import DellProAiStudioOptions from "@/components/LLMSelection/DPAISOptions";
+import MoonshotAiOptions from "@/components/LLMSelection/MoonshotAiOptions";
+
+import LLMItem from "@/components/LLMSelection/LLMItem";
+import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
+import CTAButton from "@/components/lib/CTAButton";
+
+export const AVAILABLE_LLM_PROVIDERS = [
+ {
+ name: "OpenAI",
+ value: "openai",
+ logo: OpenAiLogo,
+ options: (settings) =>
,
+ description: "The standard option for most non-commercial use.",
+ requiredConfig: ["OpenAiKey"],
+ },
+ {
+ name: "Azure OpenAI",
+ value: "azure",
+ logo: AzureOpenAiLogo,
+ options: (settings) =>
,
+ description: "The enterprise option of OpenAI hosted on Azure services.",
+ requiredConfig: ["AzureOpenAiEndpoint"],
+ },
+ {
+ name: "Anthropic",
+ value: "anthropic",
+ logo: AnthropicLogo,
+ options: (settings) =>
,
+ description: "A friendly AI Assistant hosted by Anthropic.",
+ requiredConfig: ["AnthropicApiKey"],
+ },
+ {
+ name: "Gemini",
+ value: "gemini",
+ logo: GeminiLogo,
+ options: (settings) =>
,
+ description: "Google's largest and most capable AI model",
+ requiredConfig: ["GeminiLLMApiKey"],
+ },
+ {
+ name: "NVIDIA NIM",
+ value: "nvidia-nim",
+ logo: NvidiaNimLogo,
+ options: (settings) =>
,
+ description:
+ "Run full parameter LLMs directly on your NVIDIA RTX GPU using NVIDIA NIM.",
+ requiredConfig: ["NvidiaNimLLMBasePath"],
+ },
+ {
+ name: "HuggingFace",
+ value: "huggingface",
+ logo: HuggingFaceLogo,
+ options: (settings) =>
,
+ description:
+ "Access 150,000+ open-source LLMs and the world's AI community",
+ requiredConfig: [
+ "HuggingFaceLLMEndpoint",
+ "HuggingFaceLLMAccessToken",
+ "HuggingFaceLLMTokenLimit",
+ ],
+ },
+ {
+ name: "Ollama",
+ value: "ollama",
+ logo: OllamaLogo,
+ options: (settings) =>
,
+ description: "Run LLMs locally on your own machine.",
+ requiredConfig: ["OllamaLLMBasePath"],
+ },
+ {
+ name: "Dell Pro AI Studio",
+ value: "dpais",
+ logo: DellProAiStudioLogo,
+ options: (settings) =>
,
+ description:
+ "Run powerful LLMs quickly on NPU powered by Dell Pro AI Studio.",
+ requiredConfig: [
+ "DellProAiStudioBasePath",
+ "DellProAiStudioModelPref",
+ "DellProAiStudioTokenLimit",
+ ],
+ },
+ {
+ name: "LM Studio",
+ value: "lmstudio",
+ logo: LMStudioLogo,
+ options: (settings) =>
,
+ description:
+ "Discover, download, and run thousands of cutting edge LLMs in a few clicks.",
+ requiredConfig: ["LMStudioBasePath"],
+ },
+ {
+ name: "Local AI",
+ value: "localai",
+ logo: LocalAiLogo,
+ options: (settings) =>
,
+ description: "Run LLMs locally on your own machine.",
+ requiredConfig: ["LocalAiApiKey", "LocalAiBasePath", "LocalAiTokenLimit"],
+ },
+ {
+ name: "Together AI",
+ value: "togetherai",
+ logo: TogetherAILogo,
+ options: (settings) =>
,
+ description: "Run open source models from Together AI.",
+ requiredConfig: ["TogetherAiApiKey"],
+ },
+ {
+ name: "Fireworks AI",
+ value: "fireworksai",
+ logo: FireworksAILogo,
+ options: (settings) =>
,
+ description:
+ "The fastest and most efficient inference engine to build production-ready, compound AI systems.",
+ requiredConfig: ["FireworksAiLLMApiKey"],
+ },
+ {
+ name: "Mistral",
+ value: "mistral",
+ logo: MistralLogo,
+ options: (settings) =>
,
+ description: "Run open source models from Mistral AI.",
+ requiredConfig: ["MistralApiKey"],
+ },
+ {
+ name: "Perplexity AI",
+ value: "perplexity",
+ logo: PerplexityLogo,
+ options: (settings) =>
,
+ description:
+ "Run powerful and internet-connected models hosted by Perplexity AI.",
+ requiredConfig: ["PerplexityApiKey"],
+ },
+ {
+ name: "OpenRouter",
+ value: "openrouter",
+ logo: OpenRouterLogo,
+ options: (settings) =>
,
+ description: "A unified interface for LLMs.",
+ requiredConfig: ["OpenRouterApiKey"],
+ },
+ {
+ name: "Groq",
+ value: "groq",
+ logo: GroqLogo,
+ options: (settings) =>
,
+ description:
+ "The fastest LLM inferencing available for real-time AI applications.",
+ requiredConfig: ["GroqApiKey"],
+ },
+ {
+ name: "KoboldCPP",
+ value: "koboldcpp",
+ logo: KoboldCPPLogo,
+ options: (settings) =>
,
+ description: "Run local LLMs using koboldcpp.",
+ requiredConfig: [
+ "KoboldCPPModelPref",
+ "KoboldCPPBasePath",
+ "KoboldCPPTokenLimit",
+ ],
+ },
+ {
+ name: "Oobabooga Web UI",
+ value: "textgenwebui",
+ logo: TextGenWebUILogo,
+ options: (settings) =>
,
+ description: "Run local LLMs using Oobabooga's Text Generation Web UI.",
+ requiredConfig: ["TextGenWebUIBasePath", "TextGenWebUITokenLimit"],
+ },
+ {
+ name: "Cohere",
+ value: "cohere",
+ logo: CohereLogo,
+ options: (settings) =>
,
+ description: "Run Cohere's powerful Command models.",
+ requiredConfig: ["CohereApiKey"],
+ },
+ {
+ name: "LiteLLM",
+ value: "litellm",
+ logo: LiteLLMLogo,
+ options: (settings) =>
,
+ description: "Run LiteLLM's OpenAI compatible proxy for various LLMs.",
+ requiredConfig: ["LiteLLMBasePath"],
+ },
+ {
+ name: "DeepSeek",
+ value: "deepseek",
+ logo: DeepSeekLogo,
+ options: (settings) =>
,
+ description: "Run DeepSeek's powerful LLMs.",
+ requiredConfig: ["DeepSeekApiKey"],
+ },
+ {
+ name: "PPIO",
+ value: "ppio",
+ logo: PPIOLogo,
+ options: (settings) =>
,
+ description:
+ "Run stable and cost-efficient open-source LLM APIs, such as DeepSeek, Llama, Qwen etc.",
+ requiredConfig: ["PPIOApiKey"],
+ },
+ {
+ name: "AWS Bedrock",
+ value: "bedrock",
+ logo: AWSBedrockLogo,
+ options: (settings) =>
,
+ description: "Run powerful foundation models privately with AWS Bedrock.",
+ requiredConfig: [
+ "AwsBedrockLLMAccessKeyId",
+ "AwsBedrockLLMAccessKey",
+ "AwsBedrockLLMRegion",
+ "AwsBedrockLLMModel",
+ ],
+ },
+ {
+ name: "APIpie",
+ value: "apipie",
+ logo: APIPieLogo,
+ options: (settings) =>
,
+ description: "A unified API of AI services from leading providers",
+ requiredConfig: ["ApipieLLMApiKey", "ApipieLLMModelPref"],
+ },
+ {
+ name: "Moonshot AI",
+ value: "moonshotai",
+ logo: MoonshotAiLogo,
+ options: (settings) =>
,
+ description: "Run Moonshot AI's powerful LLMs.",
+ requiredConfig: ["MoonshotAiApiKey"],
+ },
+ {
+ name: "Novita AI",
+ value: "novita",
+ logo: NovitaLogo,
+ options: (settings) =>
,
+ description:
+ "Reliable, Scalable, and Cost-Effective for LLMs from Novita AI",
+ requiredConfig: ["NovitaLLMApiKey"],
+ },
+ {
+ name: "CometAPI",
+ value: "cometapi",
+ logo: CometApiLogo,
+ options: (settings) =>
,
+ description: "500+ AI Models all in one API.",
+ requiredConfig: ["CometApiLLMApiKey"],
+ },
+ {
+ name: "xAI",
+ value: "xai",
+ logo: XAILogo,
+ options: (settings) =>
,
+ description: "Run xAI's powerful LLMs like Grok-2 and more.",
+ requiredConfig: ["XAIApiKey", "XAIModelPref"],
+ },
+ {
+ name: "Generic OpenAI",
+ value: "generic-openai",
+ logo: GenericOpenAiLogo,
+ options: (settings) =>
,
+ description:
+ "Connect to any OpenAi-compatible service via a custom configuration",
+ requiredConfig: [
+ "GenericOpenAiBasePath",
+ "GenericOpenAiModelPref",
+ "GenericOpenAiTokenLimit",
+ "GenericOpenAiKey",
+ ],
+ },
+];
+
+export default function GeneralLLMPreference() {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [settings, setSettings] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredLLMs, setFilteredLLMs] = useState([]);
+ const [selectedLLM, setSelectedLLM] = useState(null);
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+ const { t } = useTranslation();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const data = { LLMProvider: selectedLLM };
+ const formData = new FormData(form);
+
+ for (var [key, value] of formData.entries()) data[key] = value;
+ const { error } = await System.updateSystem(data);
+ setSaving(true);
+
+ if (error) {
+ showToast(`Failed to save LLM settings: ${error}`, "error");
+ } else {
+ showToast("LLM preferences saved successfully.", "success");
+ }
+ setSaving(false);
+ setHasChanges(!!error);
+ };
+
+ const updateLLMChoice = (selection) => {
+ setSearchQuery("");
+ setSelectedLLM(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ };
+
+ const handleXButton = () => {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ };
+
+ useEffect(() => {
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ setSelectedLLM(_settings?.LLMProvider);
+ setLoading(false);
+ }
+ fetchKeys();
+ }, []);
+
+ useEffect(() => {
+ const filtered = AVAILABLE_LLM_PROVIDERS.filter((llm) =>
+ llm.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredLLMs(filtered);
+ }, [searchQuery, selectedLLM]);
+
+ const selectedLLMObject = AVAILABLE_LLM_PROVIDERS.find(
+ (llm) => llm.value === selectedLLM
+ );
+ return (
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+ {t("llm.description")}
+
+
+
+ {hasChanges && (
+ handleSubmit()}
+ className="mt-3 mr-0 -mb-14 z-10"
+ >
+ {saving ? "Saving..." : "Save changes"}
+
+ )}
+
+
+ {t("llm.provider")}
+
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredLLMs.map((llm) => {
+ return (
+ updateLLMChoice(llm.value)}
+ />
+ );
+ })}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedLLMObject?.name || "None selected"}
+
+
+ {selectedLLMObject?.description ||
+ "You need to select an LLM"}
+
+
+
+
+
+ )}
+
+
setHasChanges(true)}
+ className="mt-4 flex flex-col gap-y-1"
+ >
+ {selectedLLM &&
+ AVAILABLE_LLM_PROVIDERS.find(
+ (llm) => llm.value === selectedLLM
+ )?.options?.(settings)}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/MobileConnections/ConnectionModal/bg.png b/frontend/src/pages/GeneralSettings/MobileConnections/ConnectionModal/bg.png
new file mode 100644
index 0000000000000000000000000000000000000000..760b3e895693b0f9378206c32e7bc3e3de8e8fc7
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/MobileConnections/ConnectionModal/bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bf2dcc15bbf1974caac8613e910a872364ab75900efaab31da033cedef7d0c28
+size 336523
diff --git a/frontend/src/pages/GeneralSettings/MobileConnections/ConnectionModal/index.jsx b/frontend/src/pages/GeneralSettings/MobileConnections/ConnectionModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..042be49431d92d720bff9440ad120197f70f635a
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/MobileConnections/ConnectionModal/index.jsx
@@ -0,0 +1,150 @@
+import { X } from "@phosphor-icons/react";
+import ModalWrapper from "@/components/ModalWrapper";
+import BG from "./bg.png";
+import { QRCodeSVG } from "qrcode.react";
+import { Link } from "react-router-dom";
+import { useEffect, useState } from "react";
+import MobileConnection from "@/models/mobile";
+import PreLoader from "@/components/Preloader";
+import Logo from "@/media/logo/anything-llm-infinity.png";
+import paths from "@/utils/paths";
+
+export default function MobileConnectModal({ isOpen, onClose }) {
+ return (
+
+
+
+
+
+
+
+ {/* left column */}
+
+
+ Go mobile. Stay local. AnythingLLM Mobile.
+
+
+ AnythingLLM for mobile allows you to connect or clone your
+ workspace's chats, threads and documents for you to use on the go.
+
+
+ Run with local models on your phone privately or relay chats
+ directly to this instance seamlessly.
+
+
+
+ {/* right column */}
+
+
+
+
+
+ Scan the QR code with the AnythingLLM Mobile app to enable live
+ sync of your workspaces, chats, threads and documents.
+
+
+ Learn more
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Process the connection url to make it absolute if it is a relative path
+ * @param {string} url
+ * @returns {string}
+ */
+function processConnectionUrl(url) {
+ /*
+ * In dev mode, the connectionURL() method uses the `ip` module
+ * see server/models/mobileDevice.js `connectionURL()` method.
+ *
+ * In prod mode, this method returns the absolute path since we will always want to use
+ * the real instance hostname. If the domain changes, we should be able to inherit it from the client side
+ * since the backend has no knowledge of the domain since typically it is run behind a reverse proxy or in a container - or both.
+ * So `ip` is useless in prod mode since it would only resolve to the internal IP address of the container or if non-containerized,
+ * the local IP address may not be the preferred instance access point (eg: using custom domain)
+ *
+ * If the url does not start with http, we assume it is a relative path and add the origin to it.
+ * Then we check if the hostname is localhost, 127.0.0.1, or 0.0.0.0. If it is, we throw an error since that is not
+ * a LAN resolvable address that other devices can use to connect to the instance.
+ */
+ if (url.startsWith("http")) return new URL(url);
+ const connectionUrl = new URL(`${window.location.origin}${url}`);
+ if (["localhost", "127.0.0.1", "0.0.0.0"].includes(connectionUrl.hostname))
+ throw new Error(
+ "Please open this page via your machines private IP address or custom domain. Localhost URLs will not work with the mobile app."
+ );
+ return connectionUrl.toString();
+}
+
+const ConnectionQrCode = ({ isOpen }) => {
+ const [connectionInfo, setConnectionInfo] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setIsLoading(true);
+ MobileConnection.getConnectionInfo()
+ .then((res) => {
+ if (res.error) throw new Error(res.error);
+ const url = processConnectionUrl(res.connectionUrl);
+ setConnectionInfo(url);
+ })
+ .catch((err) => {
+ setError(err.message);
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ }, [isOpen]);
+
+ if (isLoading) return
;
+ if (error)
+ return (
+
{error}
+ );
+
+ const size = {
+ width: 35 * 1.5,
+ height: 22 * 1.5,
+ };
+ return (
+
+ );
+};
diff --git a/frontend/src/pages/GeneralSettings/MobileConnections/DeviceRow/index.jsx b/frontend/src/pages/GeneralSettings/MobileConnections/DeviceRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0d8e8740e56091a9ab97195085d3a3b38d659a75
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/MobileConnections/DeviceRow/index.jsx
@@ -0,0 +1,90 @@
+import showToast from "@/utils/toast";
+import MobileConnection from "@/models/mobile";
+import { useState } from "react";
+import moment from "moment";
+import { BugDroid, AppleLogo } from "@phosphor-icons/react";
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+
+export default function DeviceRow({ device, removeDevice }) {
+ const [status, setStatus] = useState(device.approved);
+
+ const handleApprove = async () => {
+ await MobileConnection.updateDevice(device.id, { approved: true });
+ showToast("Device access granted", "info");
+ setStatus(true);
+ };
+
+ const handleDeny = async () => {
+ await MobileConnection.deleteDevice(device.id);
+ showToast("Device access denied", "info");
+ setStatus(false);
+ removeDevice(device.id);
+ };
+
+ return (
+ <>
+
+
+
+ {device.deviceOs === "ios" ? (
+
+ ) : (
+
+ )}
+
{device.deviceName}
+
+
+
+
+ {moment(device.createdAt).format("lll")}
+ {device.user && (
+
+ by
+
+ {device.user.username}
+
+
+ )}
+
+
+
+ {status ? (
+
+ Revoke
+
+ ) : (
+ <>
+
+ Approve Access
+
+
+ Deny
+
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/MobileConnections/index.jsx b/frontend/src/pages/GeneralSettings/MobileConnections/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..6ffcf153dae568cf06f46749ca9224ed1481b0f5
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/MobileConnections/index.jsx
@@ -0,0 +1,123 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { QrCode } from "@phosphor-icons/react";
+import { useModal } from "@/hooks/useModal";
+import CTAButton from "@/components/lib/CTAButton";
+import MobileConnection from "@/models/mobile";
+import ConnectionModal from "./ConnectionModal";
+import DeviceRow from "./DeviceRow";
+import { isMobile } from "react-device-detect";
+
+export default function MobileDevices() {
+ const { isOpen, openModal, closeModal } = useModal();
+ const [loading, setLoading] = useState(true);
+ const [devices, setDevices] = useState([]);
+
+ const fetchDevices = async () => {
+ const foundDevices = await MobileConnection.getDevices();
+ setDevices(foundDevices);
+ if (foundDevices.length !== 0 && !isOpen) closeModal();
+ return foundDevices;
+ };
+
+ useEffect(() => {
+ fetchDevices()
+ .then((devices) => {
+ if (devices.length === 0) openModal();
+ return devices;
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+
+ const interval = setInterval(fetchDevices, 5_000);
+ return () => clearInterval(interval);
+ }, []);
+
+ const removeDevice = (id) => {
+ setDevices((prevDevices) =>
+ prevDevices.filter((device) => device.id !== id)
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ Connected Mobile Devices
+
+
+
+ These are the devices that are connected to your desktop
+ application to sync chats, workspaces, and more.
+
+
+
+
+ Register New Device
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ Device Name
+
+
+ Registered
+
+
+ {" "}
+
+
+
+
+ {devices.length === 0 ? (
+
+
+ No devices found
+
+
+ ) : (
+ devices.map((device) => (
+
+ ))
+ )}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/PrivacyAndData/index.jsx b/frontend/src/pages/GeneralSettings/PrivacyAndData/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0305a02b0db482776384d45964de5844614df9d3
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/PrivacyAndData/index.jsx
@@ -0,0 +1,220 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import showToast from "@/utils/toast";
+import System from "@/models/system";
+import PreLoader from "@/components/Preloader";
+import {
+ EMBEDDING_ENGINE_PRIVACY,
+ LLM_SELECTION_PRIVACY,
+ VECTOR_DB_PRIVACY,
+ FALLBACKS,
+} from "@/pages/OnboardingFlow/Steps/DataHandling";
+import { useTranslation } from "react-i18next";
+
+export default function PrivacyAndDataHandling() {
+ const [settings, setSettings] = useState({});
+ const [loading, setLoading] = useState(true);
+ const { t } = useTranslation();
+ useEffect(() => {
+ async function fetchSettings() {
+ setLoading(true);
+ const settings = await System.keys();
+ setSettings(settings);
+ setLoading(false);
+ }
+ fetchSettings();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ {t("privacy.title")}
+
+
+
+ {t("privacy.description")}
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+function ThirdParty({ settings }) {
+ const llmChoice = settings?.LLMProvider || "openai";
+ const embeddingEngine = settings?.EmbeddingEngine || "openai";
+ const vectorDb = settings?.VectorDB || "lancedb";
+ const { t } = useTranslation();
+
+ const LLMSelection =
+ LLM_SELECTION_PRIVACY?.[llmChoice] || FALLBACKS.LLM(llmChoice);
+ const EmbeddingEngine =
+ EMBEDDING_ENGINE_PRIVACY?.[embeddingEngine] ||
+ FALLBACKS.EMBEDDING(embeddingEngine);
+ const VectorDb = VECTOR_DB_PRIVACY?.[vectorDb] || FALLBACKS.VECTOR(vectorDb);
+
+ return (
+
+
+
+
+ {t("privacy.llm")}
+
+
+
+
+ {LLMSelection.name}
+
+
+
+ {LLMSelection.description.map((desc) => (
+ {desc}
+ ))}
+
+
+
+
+ {t("privacy.embedding")}
+
+
+
+
+ {EmbeddingEngine.name}
+
+
+
+ {EmbeddingEngine.description.map((desc) => (
+ {desc}
+ ))}
+
+
+
+
+
+ {t("privacy.vector")}
+
+
+
+
+ {VectorDb.name}
+
+
+
+ {VectorDb.description.map((desc) => (
+ {desc}
+ ))}
+
+
+
+
+ );
+}
+
+function TelemetryLogs({ settings }) {
+ const [telemetry, setTelemetry] = useState(
+ settings?.DisableTelemetry !== "true"
+ );
+ const { t } = useTranslation();
+ async function toggleTelemetry() {
+ await System.updateSystem({
+ DisableTelemetry: !telemetry ? "false" : "true",
+ });
+ setTelemetry(!telemetry);
+ showToast(
+ `Anonymous Telemetry has been ${!telemetry ? "enabled" : "disabled"}.`,
+ "info",
+ { clear: true }
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {t("privacy.anonymous")}
+
+
+
+
+
+
+
+
+
+
+ All events do not record IP-address and contain{" "}
+ no identifying content, settings, chats, or other non-usage
+ based information. To see the list of event tags collected you can
+ look on{" "}
+
+ GitHub here
+
+ .
+
+
+ As an open-source project we respect your right to privacy. We are
+ dedicated to building the best solution for integrating AI and
+ documents privately and securely. If you do decide to turn off
+ telemetry all we ask is to consider sending us feedback and thoughts
+ so that we can continue to improve AnythingLLM for you.{" "}
+
+ team@mintplexlabs.com
+
+ .
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Security/index.jsx b/frontend/src/pages/GeneralSettings/Security/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..145e3a350b6a7804bd44c5d2f83c42a7615d8f58
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Security/index.jsx
@@ -0,0 +1,349 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import showToast from "@/utils/toast";
+import System from "@/models/system";
+import paths from "@/utils/paths";
+import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
+import PreLoader from "@/components/Preloader";
+import CTAButton from "@/components/lib/CTAButton";
+import { useTranslation } from "react-i18next";
+
+export default function GeneralSecurity() {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+ {t("security.title")}
+
+
+
+
+
+
+ );
+}
+
+function MultiUserMode() {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [useMultiUserMode, setUseMultiUserMode] = useState(false);
+ const [multiUserModeEnabled, setMultiUserModeEnabled] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const { t } = useTranslation();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSaving(true);
+ setHasChanges(false);
+ if (useMultiUserMode) {
+ const form = new FormData(e.target);
+ const data = {
+ username: form.get("username"),
+ password: form.get("password"),
+ };
+
+ const { success, error } = await System.setupMultiUser(data);
+ if (success) {
+ showToast("Multi-User mode enabled successfully.", "success");
+ setSaving(false);
+ setTimeout(() => {
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TOKEN);
+ window.localStorage.removeItem(AUTH_TIMESTAMP);
+ window.location = paths.settings.users();
+ }, 2_000);
+ return;
+ }
+
+ showToast(`Failed to enable Multi-User mode: ${error}`, "error");
+ setSaving(false);
+ return;
+ }
+ };
+
+ useEffect(() => {
+ async function fetchIsMultiUserMode() {
+ setLoading(true);
+ const multiUserModeEnabled = await System.isMultiUserMode();
+ setMultiUserModeEnabled(multiUserModeEnabled);
+ setLoading(false);
+ }
+ fetchIsMultiUserMode();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
setHasChanges(true)}
+ className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px]"
+ >
+
+
+
+
+ {t("security.multiuser.title")}
+
+
+
+ {t("security.multiuser.description")}
+
+
+ {hasChanges && (
+
+ handleSubmit()}
+ className="mt-3 mr-0 -mb-20 z-10"
+ >
+ {saving ? t("common.saving") : t("common.save")}
+
+
+ )}
+
+
+
+
+
+
+
+ {multiUserModeEnabled
+ ? t("security.multiuser.enable.is-enable")
+ : t("security.multiuser.enable.enable")}
+
+
+
+ setUseMultiUserMode(!useMultiUserMode)}
+ defaultChecked={useMultiUserMode}
+ className="peer sr-only pointer-events-none"
+ />
+
+
+
+ {useMultiUserMode && (
+
+ )}
+
+
+
+
+ {t("security.multiuser.enable.description")}
+
+
+
+
+
+
+ );
+}
+
+const PW_REGEX = new RegExp(/^[a-zA-Z0-9_\-!@$%^&*();]+$/);
+function PasswordProtection() {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [multiUserModeEnabled, setMultiUserModeEnabled] = useState(false);
+ const [usePassword, setUsePassword] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const { t } = useTranslation();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (multiUserModeEnabled) return false;
+ const form = new FormData(e.target);
+
+ if (!PW_REGEX.test(form.get("password"))) {
+ showToast(
+ `Your password has restricted characters in it. Allowed symbols are _,-,!,@,$,%,^,&,*,(,),;`,
+ "error"
+ );
+ setSaving(false);
+ return;
+ }
+
+ setSaving(true);
+ setHasChanges(false);
+ const data = {
+ usePassword,
+ newPassword: form.get("password"),
+ };
+
+ const { success, error } = await System.updateSystemPassword(data);
+ if (success) {
+ showToast("Your page will refresh in a few seconds.", "success");
+ setSaving(false);
+ setTimeout(() => {
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TOKEN);
+ window.localStorage.removeItem(AUTH_TIMESTAMP);
+ window.location.reload();
+ }, 3_000);
+ return;
+ } else {
+ showToast(`Failed to update password: ${error}`, "error");
+ setSaving(false);
+ }
+ };
+
+ useEffect(() => {
+ async function fetchIsMultiUserMode() {
+ setLoading(true);
+ const multiUserModeEnabled = await System.isMultiUserMode();
+ const settings = await System.keys();
+ setMultiUserModeEnabled(multiUserModeEnabled);
+ setUsePassword(settings?.RequiresAuth);
+ setLoading(false);
+ }
+ fetchIsMultiUserMode();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (multiUserModeEnabled) return null;
+ return (
+
setHasChanges(true)}
+ className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px]"
+ >
+
+
+
+
+ {t("security.password.title")}
+
+
+
+ {t("security.password.description")}
+
+
+ {hasChanges && (
+
+ handleSubmit()}
+ className="mt-3 mr-0 -mb-20 z-10"
+ >
+ {saving ? t("common.saving") : t("common.save")}
+
+
+ )}
+
+
+
+
+
+
+
+ {t("security.password.title")}
+
+
+
+ setUsePassword(!usePassword)}
+ defaultChecked={usePassword}
+ className="peer sr-only pointer-events-none"
+ />
+
+
+
+ {usePassword && (
+
+
+
+ {t("security.password.password-label")}
+
+
+
+
+ )}
+
+
+
+
+ {t("security.password.description")}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/Branding/index.jsx b/frontend/src/pages/GeneralSettings/Settings/Branding/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..825a69ccbdaad5ad2ca7682ef05d55857090b4c0
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/Branding/index.jsx
@@ -0,0 +1,42 @@
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import FooterCustomization from "../components/FooterCustomization";
+import SupportEmail from "../components/SupportEmail";
+import CustomLogo from "../components/CustomLogo";
+import CustomMessages from "../components/CustomMessages";
+import { useTranslation } from "react-i18next";
+import CustomAppName from "../components/CustomAppName";
+import CustomSiteSettings from "../components/CustomSiteSettings";
+
+export default function BrandingSettings() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+ {t("customization.branding.title")}
+
+
+
+ {t("customization.branding.description")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/Chat/index.jsx b/frontend/src/pages/GeneralSettings/Settings/Chat/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bd32e4ff8bff3c2d25fee51943b15d2831de470b
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/Chat/index.jsx
@@ -0,0 +1,38 @@
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import { useTranslation } from "react-i18next";
+import AutoSubmit from "../components/AutoSubmit";
+import AutoSpeak from "../components/AutoSpeak";
+import SpellCheck from "../components/SpellCheck";
+import ShowScrollbar from "../components/ShowScrollbar";
+
+export default function ChatSettings() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+ {t("customization.chat.title")}
+
+
+
+ {t("customization.chat.description")}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/Interface/index.jsx b/frontend/src/pages/GeneralSettings/Settings/Interface/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..2d82edc93a4c60d8df08d847fa1a51587631a2c9
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/Interface/index.jsx
@@ -0,0 +1,36 @@
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import { useTranslation } from "react-i18next";
+import LanguagePreference from "../components/LanguagePreference";
+import ThemePreference from "../components/ThemePreference";
+import { MessageDirection } from "../components/MessageDirection";
+
+export default function InterfaceSettings() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+ {t("customization.interface.title")}
+
+
+
+ {t("customization.interface.description")}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/AutoSpeak/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/AutoSpeak/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc85743f5fae9f1a38dcc58201762e4c12952b9d
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/AutoSpeak/index.jsx
@@ -0,0 +1,59 @@
+import React, { useState, useEffect } from "react";
+import Appearance from "@/models/appearance";
+import { useTranslation } from "react-i18next";
+
+export default function AutoSpeak() {
+ const [saving, setSaving] = useState(false);
+ const [autoPlayAssistantTtsResponse, setAutoPlayAssistantTtsResponse] =
+ useState(false);
+ const { t } = useTranslation();
+
+ const handleChange = async (e) => {
+ const newValue = e.target.checked;
+ setAutoPlayAssistantTtsResponse(newValue);
+ setSaving(true);
+ try {
+ Appearance.updateSettings({ autoPlayAssistantTtsResponse: newValue });
+ } catch (error) {
+ console.error("Failed to update appearance settings:", error);
+ setAutoPlayAssistantTtsResponse(!newValue);
+ }
+ setSaving(false);
+ };
+
+ useEffect(() => {
+ function fetchSettings() {
+ const settings = Appearance.getSettings();
+ setAutoPlayAssistantTtsResponse(
+ settings.autoPlayAssistantTtsResponse ?? false
+ );
+ }
+ fetchSettings();
+ }, []);
+
+ return (
+
+
+ {t("customization.chat.auto_speak.title")}
+
+
+ {t("customization.chat.auto_speak.description")}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/AutoSubmit/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/AutoSubmit/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bf27f8229256d220764dc80785afa2574364e47b
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/AutoSubmit/index.jsx
@@ -0,0 +1,56 @@
+import React, { useState, useEffect } from "react";
+import Appearance from "@/models/appearance";
+import { useTranslation } from "react-i18next";
+
+export default function AutoSubmit() {
+ const [saving, setSaving] = useState(false);
+ const [autoSubmitSttInput, setAutoSubmitSttInput] = useState(true);
+ const { t } = useTranslation();
+
+ const handleChange = async (e) => {
+ const newValue = e.target.checked;
+ setAutoSubmitSttInput(newValue);
+ setSaving(true);
+ try {
+ Appearance.updateSettings({ autoSubmitSttInput: newValue });
+ } catch (error) {
+ console.error("Failed to update appearance settings:", error);
+ setAutoSubmitSttInput(!newValue);
+ }
+ setSaving(false);
+ };
+
+ useEffect(() => {
+ function fetchSettings() {
+ const settings = Appearance.getSettings();
+ setAutoSubmitSttInput(settings.autoSubmitSttInput ?? true);
+ }
+ fetchSettings();
+ }, []);
+
+ return (
+
+
+ {t("customization.chat.auto_submit.title")}
+
+
+ {t("customization.chat.auto_submit.description")}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/CustomAppName/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/CustomAppName/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8fd0f0e8824425526d0076311bcdf1065ffd2955
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/CustomAppName/index.jsx
@@ -0,0 +1,103 @@
+import Admin from "@/models/admin";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export default function CustomAppName() {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(true);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [customAppName, setCustomAppName] = useState("");
+ const [originalAppName, setOriginalAppName] = useState("");
+ const [canCustomize, setCanCustomize] = useState(false);
+
+ useEffect(() => {
+ const fetchInitialParams = async () => {
+ const settings = await System.keys();
+ if (!settings?.MultiUserMode && !settings?.RequiresAuth) {
+ setCanCustomize(false);
+ return false;
+ }
+
+ const { appName } = await System.fetchCustomAppName();
+ setCustomAppName(appName || "");
+ setOriginalAppName(appName || "");
+ setCanCustomize(true);
+ setLoading(false);
+ };
+ fetchInitialParams();
+ }, []);
+
+ const updateCustomAppName = async (e, newValue = null) => {
+ e.preventDefault();
+ let custom_app_name = newValue;
+ if (newValue === null) {
+ const form = new FormData(e.target);
+ custom_app_name = form.get("customAppName");
+ }
+ const { success, error } = await Admin.updateSystemPreferences({
+ custom_app_name,
+ });
+ if (!success) {
+ showToast(`Failed to update custom app name: ${error}`, "error");
+ return;
+ } else {
+ showToast("Successfully updated custom app name.", "success");
+ window.localStorage.removeItem(System.cacheKeys.customAppName);
+ setCustomAppName(custom_app_name);
+ setOriginalAppName(custom_app_name);
+ setHasChanges(false);
+ }
+ };
+
+ const handleChange = (e) => {
+ setCustomAppName(e.target.value);
+ setHasChanges(true);
+ };
+
+ if (!canCustomize || loading) return null;
+
+ return (
+
+
+ {t("customization.items.app-name.title")}
+
+
+ {t("customization.items.app-name.description")}
+
+
+
+ {originalAppName !== "" && (
+ updateCustomAppName(e, "")}
+ className="text-white text-base font-medium hover:text-opacity-60"
+ >
+ Clear
+
+ )}
+
+ {hasChanges && (
+
+ Save
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/CustomLogo/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/CustomLogo/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..fa559746c07f809370929d340be14493c6b0cf6d
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/CustomLogo/index.jsx
@@ -0,0 +1,149 @@
+import useLogo from "@/hooks/useLogo";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { useEffect, useRef, useState } from "react";
+import { Plus } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+
+export default function CustomLogo() {
+ const { t } = useTranslation();
+ const { logo: _initLogo, setLogo: _setLogo } = useLogo();
+ const [logo, setLogo] = useState("");
+ const [isDefaultLogo, setIsDefaultLogo] = useState(true);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ async function logoInit() {
+ setLogo(_initLogo || "");
+ const _isDefaultLogo = await System.isDefaultLogo();
+ setIsDefaultLogo(_isDefaultLogo);
+ }
+ logoInit();
+ }, [_initLogo]);
+
+ const handleFileUpload = async (event) => {
+ const file = event.target.files[0];
+ if (!file) return false;
+
+ const objectURL = URL.createObjectURL(file);
+ setLogo(objectURL);
+
+ const formData = new FormData();
+ formData.append("logo", file);
+ const { success, error } = await System.uploadLogo(formData);
+ if (!success) {
+ showToast(`Failed to upload logo: ${error}`, "error");
+ setLogo(_initLogo);
+ return;
+ }
+
+ const { logoURL } = await System.fetchLogo();
+ _setLogo(logoURL);
+
+ showToast("Image uploaded successfully.", "success");
+ setIsDefaultLogo(false);
+ };
+
+ const handleRemoveLogo = async () => {
+ setLogo("");
+ setIsDefaultLogo(true);
+
+ const { success, error } = await System.removeCustomLogo();
+ if (!success) {
+ console.error("Failed to remove logo:", error);
+ showToast(`Failed to remove logo: ${error}`, "error");
+ const { logoURL } = await System.fetchLogo();
+ setLogo(logoURL);
+ setIsDefaultLogo(false);
+ return;
+ }
+
+ const { logoURL } = await System.fetchLogo();
+ _setLogo(logoURL);
+
+ showToast("Image successfully removed.", "success");
+ };
+
+ const triggerFileInputClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ return (
+
+
+ {t("customization.items.logo.title")}
+
+
+ {t("customization.items.logo.description")}
+
+ {isDefaultLogo ? (
+
+
+
+
+
+
+
+
+ {t("customization.items.logo.add")}
+
+
+ {t("customization.items.logo.recommended")}
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ {t("customization.items.logo.replace")}
+
+
+
+
+ {t("customization.items.logo.remove")}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/CustomMessages/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/CustomMessages/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..11c64459bcd16712c734749ab172a74eb87f77d7
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/CustomMessages/index.jsx
@@ -0,0 +1,139 @@
+import EditingChatBubble from "@/components/EditingChatBubble";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { Plus } from "@phosphor-icons/react";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export default function CustomMessages() {
+ const { t } = useTranslation();
+ const [hasChanges, setHasChanges] = useState(false);
+ const [messages, setMessages] = useState([]);
+
+ useEffect(() => {
+ async function fetchMessages() {
+ const messages = await System.getWelcomeMessages();
+ setMessages(messages);
+ }
+ fetchMessages();
+ }, []);
+
+ const addMessage = (type) => {
+ if (type === "user") {
+ setMessages([
+ ...messages,
+ {
+ user: t("customization.items.welcome-messages.double-click"),
+ response: "",
+ },
+ ]);
+ } else {
+ setMessages([
+ ...messages,
+ {
+ user: "",
+ response: t("customization.items.welcome-messages.double-click"),
+ },
+ ]);
+ }
+ };
+
+ const removeMessage = (index) => {
+ setHasChanges(true);
+ setMessages(messages.filter((_, i) => i !== index));
+ };
+
+ const handleMessageChange = (index, type, value) => {
+ setHasChanges(true);
+ const newMessages = [...messages];
+ newMessages[index][type] = value;
+ setMessages(newMessages);
+ };
+
+ const handleMessageSave = async () => {
+ const { success, error } = await System.setWelcomeMessages(messages);
+ if (!success) {
+ showToast(`Failed to update welcome messages: ${error}`, "error");
+ return;
+ }
+ showToast("Successfully updated welcome messages.", "success");
+ setHasChanges(false);
+ };
+
+ return (
+
+
+ {t("customization.items.welcome-messages.title")}
+
+
+ {t("customization.items.welcome-messages.description")}
+
+
+ {messages.map((message, index) => (
+
+ {message.user && (
+
+ )}
+ {message.response && (
+
+ )}
+
+ ))}
+
+
addMessage("response")}
+ >
+
+
+
+ {t("customization.items.welcome-messages.new")}{" "}
+
+ {t("customization.items.welcome-messages.system")}
+ {" "}
+ {t("customization.items.welcome-messages.message")}
+
+
+
+
addMessage("user")}
+ >
+
+
+
+ {t("customization.items.welcome-messages.new")}{" "}
+
+ {t("customization.items.welcome-messages.user")}
+ {" "}
+ {t("customization.items.welcome-messages.message")}
+
+
+
+
+
+ {hasChanges && (
+
+
+ {t("customization.items.welcome-messages.save")}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/CustomSiteSettings/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/CustomSiteSettings/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..daabd32fb13c815360021288ba8225a6e26a2262
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/CustomSiteSettings/index.jsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from "react";
+import Admin from "@/models/admin";
+import showToast from "@/utils/toast";
+import { useTranslation } from "react-i18next";
+
+export default function CustomSiteSettings() {
+ const { t } = useTranslation();
+ const [hasChanges, setHasChanges] = useState(false);
+ const [settings, setSettings] = useState({
+ title: null,
+ faviconUrl: null,
+ });
+
+ useEffect(() => {
+ Admin.systemPreferences().then(({ settings }) => {
+ setSettings({
+ title: settings?.meta_page_title,
+ faviconUrl: settings?.meta_page_favicon,
+ });
+ });
+ }, []);
+
+ async function handleSiteSettingUpdate(e) {
+ e.preventDefault();
+ await Admin.updateSystemPreferences({
+ meta_page_title: settings.title ?? null,
+ meta_page_favicon: settings.faviconUrl ?? null,
+ });
+ showToast(
+ "Site preferences updated! They will reflect on page reload.",
+ "success",
+ { clear: true }
+ );
+ setHasChanges(false);
+ return;
+ }
+
+ return (
+
setHasChanges(true)}
+ onSubmit={handleSiteSettingUpdate}
+ >
+
+ {t("customization.items.browser-appearance.title")}
+
+
+ {t("customization.items.browser-appearance.description")}
+
+
+
+
+ {t("customization.items.browser-appearance.tab.title")}
+
+
+ {t("customization.items.browser-appearance.tab.description")}
+
+
+ {
+ setSettings((prev) => {
+ return { ...prev, title: e.target.value };
+ });
+ }}
+ value={
+ settings.title ??
+ "AnythingLLM | Your personal LLM trained on anything"
+ }
+ />
+
+
+
+
+
+ {t("customization.items.browser-appearance.favicon.title")}
+
+
+ {t("customization.items.browser-appearance.favicon.description")}
+
+
+
(e.target.src = "/favicon.png")}
+ className="h-10 w-10 rounded-lg mt-2"
+ alt="Site favicon"
+ />
+
{
+ setSettings((prev) => {
+ return { ...prev, faviconUrl: e.target.value };
+ });
+ }}
+ autoComplete="off"
+ value={settings.faviconUrl ?? ""}
+ />
+
+
+
+ {hasChanges && (
+
+ Save
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/FooterCustomization/NewIconForm/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/FooterCustomization/NewIconForm/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7da20e31e582b9e0dfb22ebed77ca23870114b2e
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/FooterCustomization/NewIconForm/index.jsx
@@ -0,0 +1,117 @@
+import { ICON_COMPONENTS } from "@/components/Footer";
+import React, { useEffect, useRef, useState } from "react";
+import { Plus, X } from "@phosphor-icons/react";
+
+export default function NewIconForm({ icon, url, onSave, onRemove }) {
+ const [selectedIcon, setSelectedIcon] = useState(icon || "Plus");
+ const [selectedUrl, setSelectedUrl] = useState(url || "");
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isEdited, setIsEdited] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ setSelectedIcon(icon || "Plus");
+ setSelectedUrl(url || "");
+ setIsEdited(false);
+ }, [icon, url]);
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsDropdownOpen(false);
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [dropdownRef]);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (selectedIcon !== "Plus" && selectedUrl) {
+ onSave(selectedIcon, selectedUrl);
+ setIsEdited(false);
+ }
+ };
+
+ const handleRemove = () => {
+ onRemove();
+ setSelectedIcon("Plus");
+ setSelectedUrl("");
+ setIsEdited(false);
+ };
+
+ const handleIconChange = (iconName) => {
+ setSelectedIcon(iconName);
+ setIsDropdownOpen(false);
+ setIsEdited(true);
+ };
+
+ const handleUrlChange = (e) => {
+ setSelectedUrl(e.target.value);
+ setIsEdited(true);
+ };
+
+ return (
+
+
+
setIsDropdownOpen(!isDropdownOpen)}
+ >
+ {React.createElement(ICON_COMPONENTS[selectedIcon] || Plus, {
+ className: "h-5 w-5",
+ weight: selectedIcon === "Plus" ? "bold" : "fill",
+ color: "var(--theme-sidebar-footer-icon-fill)",
+ })}
+
+ {isDropdownOpen && (
+
+ {Object.keys(ICON_COMPONENTS).map((iconName) => (
+ handleIconChange(iconName)}
+ >
+ {React.createElement(ICON_COMPONENTS[iconName], {
+ className: "h-5 w-5",
+ weight: "fill",
+ color: "var(--theme-sidebar-footer-icon-fill)",
+ })}
+
+ ))}
+
+ )}
+
+
+ {selectedIcon !== "Plus" && (
+ <>
+ {isEdited ? (
+
+ Save
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/FooterCustomization/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/FooterCustomization/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3a320454dd36c74c9ea5a7e2e983e2c9aa64f2c2
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/FooterCustomization/index.jsx
@@ -0,0 +1,81 @@
+import React, { useState, useEffect } from "react";
+import showToast from "@/utils/toast";
+import { safeJsonParse } from "@/utils/request";
+import NewIconForm from "./NewIconForm";
+import Admin from "@/models/admin";
+import System from "@/models/system";
+import { useTranslation } from "react-i18next";
+
+export default function FooterCustomization() {
+ const [footerIcons, setFooterIcons] = useState(Array(3).fill(null));
+ const { t } = useTranslation();
+ useEffect(() => {
+ async function fetchFooterIcons() {
+ const settings = (await Admin.systemPreferences())?.settings;
+ if (settings && settings.footer_data) {
+ const parsedIcons = safeJsonParse(settings.footer_data, []);
+ setFooterIcons((prevIcons) => {
+ const updatedIcons = [...prevIcons];
+ parsedIcons.forEach((icon, index) => {
+ updatedIcons[index] = icon;
+ });
+ return updatedIcons;
+ });
+ }
+ }
+ fetchFooterIcons();
+ }, []);
+
+ const updateFooterIcons = async (updatedIcons) => {
+ const { success, error } = await Admin.updateSystemPreferences({
+ footer_data: JSON.stringify(updatedIcons.filter((icon) => icon !== null)),
+ });
+
+ if (!success) {
+ showToast(`Failed to update footer icons - ${error}`, "error", {
+ clear: true,
+ });
+ return;
+ }
+
+ window.localStorage.removeItem(System.cacheKeys.footerIcons);
+ setFooterIcons(updatedIcons);
+ showToast("Successfully updated footer icons.", "success", { clear: true });
+ };
+
+ const handleRemoveIcon = (index) => {
+ const updatedIcons = [...footerIcons];
+ updatedIcons[index] = null;
+ updateFooterIcons(updatedIcons);
+ };
+
+ return (
+
+
+ {t("customization.items.sidebar-footer.title")}
+
+
+ {t("customization.items.sidebar-footer.description")}
+
+
+
{t("customization.items.sidebar-footer.icon")}
+
{t("customization.items.sidebar-footer.link")}
+
+
+ {footerIcons.map((icon, index) => (
+ {
+ const updatedIcons = [...footerIcons];
+ updatedIcons[index] = { icon: newIcon, url: newUrl };
+ updateFooterIcons(updatedIcons);
+ }}
+ onRemove={() => handleRemoveIcon(index)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/LanguagePreference/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/LanguagePreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..633a535731c7135411425f4f0179d99a1823b862
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/LanguagePreference/index.jsx
@@ -0,0 +1,39 @@
+import { useLanguageOptions } from "@/hooks/useLanguageOptions";
+import { useTranslation } from "react-i18next";
+
+export default function LanguagePreference() {
+ const { t } = useTranslation();
+ const {
+ currentLanguage,
+ supportedLanguages,
+ getLanguageName,
+ changeLanguage,
+ } = useLanguageOptions();
+
+ return (
+
+
+ {t("customization.items.display-language.title")}
+
+
+ {t("customization.items.display-language.description")}
+
+
+ changeLanguage(e.target.value)}
+ >
+ {supportedLanguages.map((lang) => {
+ return (
+
+ {getLanguageName(lang)}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/MessageDirection/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/MessageDirection/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3f6023ba8af35db2776f5ad02e4dddda3e891c63
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/MessageDirection/index.jsx
@@ -0,0 +1,69 @@
+import { useChatMessageAlignment } from "@/hooks/useChatMessageAlignment";
+import { Tooltip } from "react-tooltip";
+import { useTranslation } from "react-i18next";
+
+export function MessageDirection() {
+ const { t } = useTranslation();
+ const { msgDirection, setMsgDirection } = useChatMessageAlignment();
+
+ return (
+
+
+ {t("customization.items.chat-message-alignment.title")}
+
+
+ {t("customization.items.chat-message-alignment.description")}
+
+
+ {
+ setMsgDirection("left");
+ }}
+ />
+ {
+ setMsgDirection("left_right");
+ }}
+ />
+
+
+
+ );
+}
+
+function ItemDirection({ active, reverse, onSelect, msg }) {
+ return (
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/ShowScrollbar/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/ShowScrollbar/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..16ba073a5ba6b23cb8ffa1244ae8cbffe7776620
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/ShowScrollbar/index.jsx
@@ -0,0 +1,56 @@
+import React, { useState, useEffect } from "react";
+import Appearance from "@/models/appearance";
+import { useTranslation } from "react-i18next";
+
+export default function ShowScrollbar() {
+ const { t } = useTranslation();
+ const [saving, setSaving] = useState(false);
+ const [showScrollbar, setShowScrollbar] = useState(false);
+
+ const handleChange = async (e) => {
+ const newValue = e.target.checked;
+ setShowScrollbar(newValue);
+ setSaving(true);
+ try {
+ Appearance.updateSettings({ showScrollbar: newValue });
+ } catch (error) {
+ console.error("Failed to update appearance settings:", error);
+ setShowScrollbar(!newValue);
+ }
+ setSaving(false);
+ };
+
+ useEffect(() => {
+ function fetchSettings() {
+ const settings = Appearance.getSettings();
+ setShowScrollbar(settings.showScrollbar);
+ }
+ fetchSettings();
+ }, []);
+
+ return (
+
+
+ {t("customization.items.show-scrollbar.title")}
+
+
+ {t("customization.items.show-scrollbar.description")}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/SpellCheck/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/SpellCheck/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..933c4800e6a0ebf3244e550cb73c196423447d9d
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/SpellCheck/index.jsx
@@ -0,0 +1,50 @@
+import React, { useState, useEffect } from "react";
+import Appearance from "@/models/appearance";
+import { useTranslation } from "react-i18next";
+
+export default function SpellCheck() {
+ const { t } = useTranslation();
+ const [saving, setSaving] = useState(false);
+ const [enableSpellCheck, setEnableSpellCheck] = useState(
+ Appearance.get("enableSpellCheck")
+ );
+
+ const handleChange = async (e) => {
+ const newValue = e.target.checked;
+ setEnableSpellCheck(newValue);
+ setSaving(true);
+ try {
+ Appearance.set("enableSpellCheck", newValue);
+ } catch (error) {
+ console.error("Failed to update appearance settings:", error);
+ setEnableSpellCheck(!newValue);
+ }
+ setSaving(false);
+ };
+
+ return (
+
+
+ {t("customization.chat.spellcheck.title")}
+
+
+ {t("customization.chat.spellcheck.description")}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/SupportEmail/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/SupportEmail/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0fd4343ed7d2ce40d70631cdba8ba1027b5bf9dc
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/SupportEmail/index.jsx
@@ -0,0 +1,98 @@
+import useUser from "@/hooks/useUser";
+import Admin from "@/models/admin";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export default function SupportEmail() {
+ const { user } = useUser();
+ const [loading, setLoading] = useState(true);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [supportEmail, setSupportEmail] = useState("");
+ const [originalEmail, setOriginalEmail] = useState("");
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ const fetchSupportEmail = async () => {
+ const supportEmail = await System.fetchSupportEmail();
+ setSupportEmail(supportEmail.email || "");
+ setOriginalEmail(supportEmail.email || "");
+ setLoading(false);
+ };
+ fetchSupportEmail();
+ }, []);
+
+ const updateSupportEmail = async (e, newValue = null) => {
+ e.preventDefault();
+ let support_email = newValue;
+ if (newValue === null) {
+ const form = new FormData(e.target);
+ support_email = form.get("supportEmail");
+ }
+
+ const { success, error } = await Admin.updateSystemPreferences({
+ support_email,
+ });
+
+ if (!success) {
+ showToast(`Failed to update support email: ${error}`, "error");
+ return;
+ } else {
+ showToast("Successfully updated support email.", "success");
+ window.localStorage.removeItem(System.cacheKeys.supportEmail);
+ setSupportEmail(support_email);
+ setOriginalEmail(support_email);
+ setHasChanges(false);
+ }
+ };
+
+ const handleChange = (e) => {
+ setSupportEmail(e.target.value);
+ setHasChanges(true);
+ };
+
+ if (loading || !user?.role) return null;
+ return (
+
+
+ {t("customization.items.support-email.title")}
+
+
+ {t("customization.items.support-email.description")}
+
+
+
+ {originalEmail !== "" && (
+ updateSupportEmail(e, "")}
+ className="text-white text-base font-medium hover:text-opacity-60"
+ >
+ Clear
+
+ )}
+
+ {hasChanges && (
+
+ Save
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/Settings/components/ThemePreference/index.jsx b/frontend/src/pages/GeneralSettings/Settings/components/ThemePreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8a8f60c6141f2b67a490d03166ebf0ed76747408
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/Settings/components/ThemePreference/index.jsx
@@ -0,0 +1,31 @@
+import { useTheme } from "@/hooks/useTheme";
+import { useTranslation } from "react-i18next";
+
+export default function ThemePreference() {
+ const { t } = useTranslation();
+ const { theme, setTheme, availableThemes } = useTheme();
+
+ return (
+
+
+ {t("customization.items.theme.title")}
+
+
+ {t("customization.items.theme.description")}
+
+
+ setTheme(e.target.value)}
+ className="border-none bg-theme-settings-input-bg mt-2 text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-fit py-2 px-4"
+ >
+ {Object.entries(availableThemes).map(([key, value]) => (
+
+ {value}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/TranscriptionPreference/index.jsx b/frontend/src/pages/GeneralSettings/TranscriptionPreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ba02d842f34473eee7b84e6e92aeeab8d01dcf27
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/TranscriptionPreference/index.jsx
@@ -0,0 +1,237 @@
+import React, { useEffect, useState, useRef } from "react";
+import { isMobile } from "react-device-detect";
+import Sidebar from "@/components/SettingsSidebar";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import PreLoader from "@/components/Preloader";
+import OpenAiLogo from "@/media/llmprovider/openai.png";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import OpenAiWhisperOptions from "@/components/TranscriptionSelection/OpenAiOptions";
+import NativeTranscriptionOptions from "@/components/TranscriptionSelection/NativeTranscriptionOptions";
+import LLMItem from "@/components/LLMSelection/LLMItem";
+import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
+import CTAButton from "@/components/lib/CTAButton";
+import { useTranslation } from "react-i18next";
+
+const PROVIDERS = [
+ {
+ name: "OpenAI",
+ value: "openai",
+ logo: OpenAiLogo,
+ options: (settings) =>
,
+ description: "Leverage the OpenAI Whisper-large model using your API key.",
+ },
+ {
+ name: "AnythingLLM Built-In",
+ value: "local",
+ logo: AnythingLLMIcon,
+ options: (settings) =>
,
+ description: "Run a built-in whisper model on this instance privately.",
+ },
+];
+
+export default function TranscriptionModelPreference() {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [settings, setSettings] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredProviders, setFilteredProviders] = useState([]);
+ const [selectedProvider, setSelectedProvider] = useState(null);
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+ const { t } = useTranslation();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const data = { WhisperProvider: selectedProvider };
+ const formData = new FormData(form);
+
+ for (var [key, value] of formData.entries()) data[key] = value;
+ const { error } = await System.updateSystem(data);
+ setSaving(true);
+
+ if (error) {
+ showToast(`Failed to save preferences: ${error}`, "error");
+ } else {
+ showToast("Transcription preferences saved successfully.", "success");
+ }
+ setSaving(false);
+ setHasChanges(!!error);
+ };
+
+ const updateProviderChoice = (selection) => {
+ setSearchQuery("");
+ setSelectedProvider(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ };
+
+ const handleXButton = () => {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ };
+
+ useEffect(() => {
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ setSelectedProvider(_settings?.WhisperProvider || "local");
+ setLoading(false);
+ }
+ fetchKeys();
+ }, []);
+
+ useEffect(() => {
+ const filtered = PROVIDERS.filter((provider) =>
+ provider.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredProviders(filtered);
+ }, [searchQuery, selectedProvider]);
+
+ const selectedProviderObject = PROVIDERS.find(
+ (provider) => provider.value === selectedProvider
+ );
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+ {t("transcription.title")}
+
+
+
+ {t("transcription.description")}
+
+
+
+ {hasChanges && (
+ handleSubmit()}
+ className="mt-3 mr-0 -mb-14 z-10"
+ >
+ {saving ? "Saving..." : "Save changes"}
+
+ )}
+
+
+ {t("transcription.provider")}
+
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredProviders.map((provider) => (
+ updateProviderChoice(provider.value)}
+ />
+ ))}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedProviderObject.name}
+
+
+ {selectedProviderObject.description}
+
+
+
+
+
+ )}
+
+
setHasChanges(true)}
+ className="mt-4 flex flex-col gap-y-1"
+ >
+ {selectedProvider &&
+ PROVIDERS.find(
+ (provider) => provider.value === selectedProvider
+ )?.options(settings)}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/VectorDatabase/index.jsx b/frontend/src/pages/GeneralSettings/VectorDatabase/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..864e5533ff822a3e3c77f5784edccf7bb6b0e69f
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/VectorDatabase/index.jsx
@@ -0,0 +1,338 @@
+import React, { useState, useEffect, useRef } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { useModal } from "@/hooks/useModal";
+import CTAButton from "@/components/lib/CTAButton";
+import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+import PreLoader from "@/components/Preloader";
+import ChangeWarningModal from "@/components/ChangeWarning";
+import ModalWrapper from "@/components/ModalWrapper";
+import VectorDBItem from "@/components/VectorDBSelection/VectorDBItem";
+
+import LanceDbLogo from "@/media/vectordbs/lancedb.png";
+import ChromaLogo from "@/media/vectordbs/chroma.png";
+import PineconeLogo from "@/media/vectordbs/pinecone.png";
+import WeaviateLogo from "@/media/vectordbs/weaviate.png";
+import QDrantLogo from "@/media/vectordbs/qdrant.png";
+import MilvusLogo from "@/media/vectordbs/milvus.png";
+import ZillizLogo from "@/media/vectordbs/zilliz.png";
+import AstraDBLogo from "@/media/vectordbs/astraDB.png";
+import PGVectorLogo from "@/media/vectordbs/pgvector.png";
+
+import LanceDBOptions from "@/components/VectorDBSelection/LanceDBOptions";
+import ChromaDBOptions from "@/components/VectorDBSelection/ChromaDBOptions";
+import ChromaCloudOptions from "@/components/VectorDBSelection/ChromaCloudOptions";
+import PineconeDBOptions from "@/components/VectorDBSelection/PineconeDBOptions";
+import WeaviateDBOptions from "@/components/VectorDBSelection/WeaviateDBOptions";
+import QDrantDBOptions from "@/components/VectorDBSelection/QDrantDBOptions";
+import MilvusDBOptions from "@/components/VectorDBSelection/MilvusDBOptions";
+import ZillizCloudOptions from "@/components/VectorDBSelection/ZillizCloudOptions";
+import AstraDBOptions from "@/components/VectorDBSelection/AstraDBOptions";
+import PGVectorOptions from "@/components/VectorDBSelection/PGVectorOptions";
+
+export default function GeneralVectorDatabase() {
+ const [saving, setSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [hasEmbeddings, setHasEmbeddings] = useState(false);
+ const [settings, setSettings] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredVDBs, setFilteredVDBs] = useState([]);
+ const [selectedVDB, setSelectedVDB] = useState(null);
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+ const { isOpen, openModal, closeModal } = useModal();
+ const { t } = useTranslation();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (selectedVDB !== settings?.VectorDB && hasChanges && hasEmbeddings) {
+ openModal();
+ } else {
+ await handleSaveSettings();
+ }
+ };
+
+ const handleSaveSettings = async () => {
+ setSaving(true);
+ const form = document.getElementById("vectordb-form");
+ const settingsData = {};
+ const formData = new FormData(form);
+ settingsData.VectorDB = selectedVDB;
+ for (var [key, value] of formData.entries()) settingsData[key] = value;
+
+ const { error } = await System.updateSystem(settingsData);
+ if (error) {
+ showToast(`Failed to save vector database settings: ${error}`, "error");
+ setHasChanges(true);
+ } else {
+ showToast("Vector database preferences saved successfully.", "success");
+ setHasChanges(false);
+ }
+ setSaving(false);
+ closeModal();
+ };
+
+ const updateVectorChoice = (selection) => {
+ setSearchQuery("");
+ setSelectedVDB(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ };
+
+ const handleXButton = () => {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ };
+
+ useEffect(() => {
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ setSelectedVDB(_settings?.VectorDB || "lancedb");
+ setHasEmbeddings(_settings?.HasExistingEmbeddings || false);
+ setLoading(false);
+ }
+ fetchKeys();
+ }, []);
+
+ useEffect(() => {
+ const filtered = VECTOR_DBS.filter((vdb) =>
+ vdb.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredVDBs(filtered);
+ }, [searchQuery, selectedVDB]);
+
+ const VECTOR_DBS = [
+ {
+ name: "LanceDB",
+ value: "lancedb",
+ logo: LanceDbLogo,
+ options:
,
+ description:
+ "100% local vector DB that runs on the same instance as AnythingLLM.",
+ },
+ {
+ name: "PGVector",
+ value: "pgvector",
+ logo: PGVectorLogo,
+ options:
,
+ description: "Vector search powered by PostgreSQL.",
+ },
+ {
+ name: "Chroma",
+ value: "chroma",
+ logo: ChromaLogo,
+ options:
,
+ description:
+ "Open source vector database you can host yourself or on the cloud.",
+ },
+ {
+ name: "Chroma Cloud",
+ value: "chromacloud",
+ logo: ChromaLogo,
+ options:
,
+ description:
+ "Fully managed Chroma cloud service with enterprise features and support.",
+ },
+ {
+ name: "Pinecone",
+ value: "pinecone",
+ logo: PineconeLogo,
+ options:
,
+ description: "100% cloud-based vector database for enterprise use cases.",
+ },
+ {
+ name: "Zilliz Cloud",
+ value: "zilliz",
+ logo: ZillizLogo,
+ options:
,
+ description:
+ "Cloud hosted vector database built for enterprise with SOC 2 compliance.",
+ },
+ {
+ name: "QDrant",
+ value: "qdrant",
+ logo: QDrantLogo,
+ options:
,
+ description: "Open source local and distributed cloud vector database.",
+ },
+ {
+ name: "Weaviate",
+ value: "weaviate",
+ logo: WeaviateLogo,
+ options:
,
+ description:
+ "Open source local and cloud hosted multi-modal vector database.",
+ },
+ {
+ name: "Milvus",
+ value: "milvus",
+ logo: MilvusLogo,
+ options:
,
+ description: "Open-source, highly scalable, and blazing fast.",
+ },
+ {
+ name: "AstraDB",
+ value: "astra",
+ logo: AstraDBLogo,
+ options:
,
+ description: "Vector Search for Real-world GenAI.",
+ },
+ ];
+
+ const selectedVDBObject = VECTOR_DBS.find((vdb) => vdb.value === selectedVDB);
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+ {t("vector.title")}
+
+
+
+ {t("vector.description")}
+
+
+
+ {hasChanges && (
+ handleSubmit()}
+ className="mt-3 mr-0 -mb-14 z-10"
+ >
+ {saving ? t("common.saving") : t("common.save")}
+
+ )}
+
+
+ {t("vector.provider.title")}
+
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredVDBs.map((vdb) => (
+ updateVectorChoice(vdb.value)}
+ />
+ ))}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedVDBObject.name}
+
+
+ {selectedVDBObject.description}
+
+
+
+
+
+ )}
+
+
setHasChanges(true)}
+ className="mt-4 flex flex-col gap-y-1"
+ >
+ {selectedVDB &&
+ VECTOR_DBS.find((vdb) => vdb.value === selectedVDB)?.options}
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Invite/NewUserModal/index.jsx b/frontend/src/pages/Invite/NewUserModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b3d0d0c7b789bfb0dcf0f26db7a7a936acc05deb
--- /dev/null
+++ b/frontend/src/pages/Invite/NewUserModal/index.jsx
@@ -0,0 +1,97 @@
+import React, { useState } from "react";
+import Invite from "@/models/invite";
+import paths from "@/utils/paths";
+import { useParams } from "react-router-dom";
+import { AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
+import System from "@/models/system";
+
+export default function NewUserModal() {
+ const { code } = useParams();
+ const [error, setError] = useState(null);
+
+ const handleCreate = async (e) => {
+ setError(null);
+ e.preventDefault();
+ const data = {};
+ const form = new FormData(e.target);
+ for (var [key, value] of form.entries()) data[key] = value;
+ const { success, error } = await Invite.acceptInvite(code, data);
+ if (success) {
+ const { valid, user, token, message } = await System.requestToken(data);
+ if (valid && !!token && !!user) {
+ window.localStorage.setItem(AUTH_USER, JSON.stringify(user));
+ window.localStorage.setItem(AUTH_TOKEN, token);
+ window.location = paths.home();
+ } else {
+ setError(message);
+ }
+ return;
+ }
+ setError(error);
+ };
+
+ return (
+
+
+
+
+ Create a new account
+
+
+
+
+
+
+
+ Username
+
+
+
+
+
+ Password
+
+
+
+ {error &&
Error: {error}
}
+
+ After creating your account you will be able to login with these
+ credentials and start using workspaces.
+
+
+
+
+
+ Accept Invitation
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Invite/index.jsx b/frontend/src/pages/Invite/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..e0f3c58d4f808706b205bfd18c7f77a9866be2fc
--- /dev/null
+++ b/frontend/src/pages/Invite/index.jsx
@@ -0,0 +1,56 @@
+import React, { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import { FullScreenLoader } from "@/components/Preloader";
+import Invite from "@/models/invite";
+import NewUserModal from "./NewUserModal";
+import ModalWrapper from "@/components/ModalWrapper";
+
+export default function InvitePage() {
+ const { code } = useParams();
+ const [result, setResult] = useState({
+ status: "loading",
+ message: null,
+ });
+
+ useEffect(() => {
+ async function checkInvite() {
+ if (!code) {
+ setResult({
+ status: "invalid",
+ message: "No invite code provided.",
+ });
+ return;
+ }
+ const { invite, error } = await Invite.checkInvite(code);
+ setResult({
+ status: invite ? "valid" : "invalid",
+ message: error,
+ });
+ }
+ checkInvite();
+ }, []);
+
+ if (result.status === "loading") {
+ return (
+
+
+
+ );
+ }
+
+ if (result.status === "invalid") {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Login/SSO/simple.jsx b/frontend/src/pages/Login/SSO/simple.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cfef761f9e26d5b4baa89db0fde40e71649e39e4
--- /dev/null
+++ b/frontend/src/pages/Login/SSO/simple.jsx
@@ -0,0 +1,53 @@
+import React, { useEffect, useState } from "react";
+import { FullScreenLoader } from "@/components/Preloader";
+import paths from "@/utils/paths";
+import useQuery from "@/hooks/useQuery";
+import System from "@/models/system";
+import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
+
+export default function SimpleSSOPassthrough() {
+ const query = useQuery();
+ const redirectPath = query.get("redirectTo") || paths.home();
+ const [ready, setReady] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ try {
+ if (!query.get("token")) throw new Error("No token provided.");
+
+ // Clear any existing auth data
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TOKEN);
+ window.localStorage.removeItem(AUTH_TIMESTAMP);
+
+ System.simpleSSOLogin(query.get("token"))
+ .then((res) => {
+ if (!res.valid) throw new Error(res.message);
+
+ window.localStorage.setItem(AUTH_USER, JSON.stringify(res.user));
+ window.localStorage.setItem(AUTH_TOKEN, res.token);
+ window.localStorage.setItem(AUTH_TIMESTAMP, Number(new Date()));
+ setReady(res.valid);
+ })
+ .catch((e) => {
+ setError(e.message);
+ });
+ } catch (e) {
+ setError(e.message);
+ }
+ }, []);
+
+ if (error)
+ return (
+
+
{error}
+
+ Please contact the system administrator about this error.
+
+
+ );
+ if (ready) return window.location.replace(redirectPath);
+
+ // Loading state by default
+ return
;
+}
diff --git a/frontend/src/pages/Login/index.jsx b/frontend/src/pages/Login/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7189e12a0d4a2812ccbba2c931f0b66eca027be9
--- /dev/null
+++ b/frontend/src/pages/Login/index.jsx
@@ -0,0 +1,36 @@
+import React from "react";
+import PasswordModal, { usePasswordModal } from "@/components/Modals/Password";
+import { FullScreenLoader } from "@/components/Preloader";
+import { Navigate } from "react-router-dom";
+import paths from "@/utils/paths";
+import useQuery from "@/hooks/useQuery";
+import useSimpleSSO from "@/hooks/useSimpleSSO";
+
+/**
+ * Login page that handles both single and multi-user login.
+ *
+ * If Simple SSO is enabled and no login is allowed, the user will be redirected to the SSO login page
+ * which may not have a token so the login will fail.
+ *
+ * @returns {JSX.Element}
+ */
+export default function Login() {
+ const query = useQuery();
+ const { loading: ssoLoading, ssoConfig } = useSimpleSSO();
+ const { loading, requiresAuth, mode } = usePasswordModal(!!query.get("nt"));
+
+ if (loading || ssoLoading) return
;
+
+ // If simple SSO is enabled and no login is allowed, redirect to the SSO login page.
+ if (ssoConfig.enabled && ssoConfig.noLogin) {
+ // If a noLoginRedirect is provided and no token is provided, redirect to that webpage.
+ if (!!ssoConfig.noLoginRedirect && !query.has("token"))
+ return window.location.replace(ssoConfig.noLoginRedirect);
+ // Otherwise, redirect to the SSO login page.
+ else return
;
+ }
+
+ if (requiresAuth === false) return
;
+
+ return
;
+}
diff --git a/frontend/src/pages/Main/Home/Checklist/ChecklistItem/icons/SlashCommand.jsx b/frontend/src/pages/Main/Home/Checklist/ChecklistItem/icons/SlashCommand.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..4e981a082cd343fdf2be9380af070505bf92efa4
--- /dev/null
+++ b/frontend/src/pages/Main/Home/Checklist/ChecklistItem/icons/SlashCommand.jsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+export default function SlashCommandIcon({ className }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Main/Home/Checklist/ChecklistItem/index.jsx b/frontend/src/pages/Main/Home/Checklist/ChecklistItem/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..291b6537a8ab5b995f85db80874b1a1b93cf1223
--- /dev/null
+++ b/frontend/src/pages/Main/Home/Checklist/ChecklistItem/index.jsx
@@ -0,0 +1,81 @@
+import { useState } from "react";
+import { CHECKLIST_STORAGE_KEY, CHECKLIST_UPDATED_EVENT } from "../constants";
+import { Check } from "@phosphor-icons/react";
+import { safeJsonParse } from "@/utils/request";
+
+export function ChecklistItem({ id, title, action, onAction, icon: Icon }) {
+ const [isCompleted, setIsCompleted] = useState(() => {
+ const stored = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
+ if (!stored) return false;
+ const completedItems = safeJsonParse(stored, {});
+ return completedItems[id] || false;
+ });
+
+ const handleClick = async (e) => {
+ e.preventDefault();
+ if (!isCompleted) {
+ const shouldComplete = await onAction();
+ if (shouldComplete) {
+ const stored = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
+ const completedItems = stored ? JSON.parse(stored) : {};
+ completedItems[id] = true;
+ window.localStorage.setItem(
+ CHECKLIST_STORAGE_KEY,
+ JSON.stringify(completedItems)
+ );
+ setIsCompleted(true);
+ window.dispatchEvent(new CustomEvent(CHECKLIST_UPDATED_EVENT));
+ }
+ } else {
+ await onAction();
+ }
+ };
+
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
+
+ {title}
+
+
+ {isCompleted ? (
+
+
+
+ ) : (
+
+ {action}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Main/Home/Checklist/constants.js b/frontend/src/pages/Main/Home/Checklist/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..667889bc6f35594db628c847099800d24e1ad410
--- /dev/null
+++ b/frontend/src/pages/Main/Home/Checklist/constants.js
@@ -0,0 +1,168 @@
+import {
+ SquaresFour,
+ ChatDots,
+ Files,
+ ChatCenteredText,
+ UsersThree,
+} from "@phosphor-icons/react";
+import SlashCommandIcon from "./ChecklistItem/icons/SlashCommand";
+import paths from "@/utils/paths";
+import { t } from "i18next";
+
+const noop = () => {};
+
+export const CHECKLIST_UPDATED_EVENT = "anythingllm_checklist_updated";
+export const CHECKLIST_STORAGE_KEY = "anythingllm_checklist_completed";
+export const CHECKLIST_HIDDEN = "anythingllm_checklist_dismissed";
+
+/**
+ * @typedef {Object} ChecklistItemHandlerParams
+ * @property {Object[]} workspaces - Array of workspaces
+ * @property {Function} navigate - Function to navigate to a path
+ * @property {Function} setSelectedWorkspace - Function to set the selected workspace
+ * @property {Function} showManageWsModal - Function to show the manage workspace modal
+ * @property {Function} showToast - Function to show a toast
+ * @property {Function} showNewWsModal - Function to show the new workspace modal
+ */
+
+/**
+ * @typedef {Object} ChecklistItem
+ * @property {string} id
+ * @property {string} title
+ * @property {string} description
+ * @property {string} action
+ * @property {(params: ChecklistItemHandlerParams) => boolean} handler
+ * @property {string} icon
+ * @property {boolean} completed
+ */
+
+/**
+ * Function to generate the checklist items
+ * @returns {ChecklistItem[]}
+ */
+export const CHECKLIST_ITEMS = () => [
+ {
+ id: "create_workspace",
+ title: t("main-page.checklist.tasks.create_workspace.title"),
+ description: t("main-page.checklist.tasks.create_workspace.description"),
+ action: t("main-page.checklist.tasks.create_workspace.action"),
+ handler: ({ showNewWsModal = noop }) => {
+ showNewWsModal();
+ return true;
+ },
+ icon: SquaresFour,
+ },
+ {
+ id: "send_chat",
+ title: t("main-page.checklist.tasks.send_chat.title"),
+ description: t("main-page.checklist.tasks.send_chat.description"),
+ action: t("main-page.checklist.tasks.send_chat.action"),
+ handler: ({
+ workspaces = [],
+ navigate = noop,
+ showToast = noop,
+ showNewWsModal = noop,
+ }) => {
+ if (workspaces.length === 0) {
+ showToast(t("main-page.noWorkspaceError"), "warning", {
+ clear: true,
+ });
+ showNewWsModal();
+ return false;
+ }
+ navigate(paths.workspace.chat(workspaces[0].slug));
+ return true;
+ },
+ icon: ChatDots,
+ },
+ {
+ id: "embed_document",
+ title: t("main-page.checklist.tasks.embed_document.title"),
+ description: t("main-page.checklist.tasks.embed_document.description"),
+ action: t("main-page.checklist.tasks.embed_document.action"),
+ handler: ({
+ workspaces = [],
+ setSelectedWorkspace = noop,
+ showManageWsModal = noop,
+ showToast = noop,
+ showNewWsModal = noop,
+ }) => {
+ if (workspaces.length === 0) {
+ showToast(t("main-page.noWorkspaceError"), "warning", {
+ clear: true,
+ });
+ showNewWsModal();
+ return false;
+ }
+ setSelectedWorkspace(workspaces[0]);
+ showManageWsModal();
+ return true;
+ },
+ icon: Files,
+ },
+ {
+ id: "setup_system_prompt",
+ title: t("main-page.checklist.tasks.setup_system_prompt.title"),
+ description: t("main-page.checklist.tasks.setup_system_prompt.description"),
+ action: t("main-page.checklist.tasks.setup_system_prompt.action"),
+ handler: ({
+ workspaces = [],
+ navigate = noop,
+ showNewWsModal = noop,
+ showToast = noop,
+ }) => {
+ if (workspaces.length === 0) {
+ showToast(t("main-page.noWorkspaceError"), "warning", {
+ clear: true,
+ });
+ showNewWsModal();
+ return false;
+ }
+ navigate(
+ paths.workspace.settings.chatSettings(workspaces[0].slug, {
+ search: { action: "focus-system-prompt" },
+ })
+ );
+ return true;
+ },
+ icon: ChatCenteredText,
+ },
+ {
+ id: "define_slash_command",
+ title: t("main-page.checklist.tasks.define_slash_command.title"),
+ description: t(
+ "main-page.checklist.tasks.define_slash_command.description"
+ ),
+ action: t("main-page.checklist.tasks.define_slash_command.action"),
+ handler: ({
+ workspaces = [],
+ navigate = noop,
+ showNewWsModal = noop,
+ showToast = noop,
+ }) => {
+ if (workspaces.length === 0) {
+ showToast(t("main-page.noWorkspaceError"), "warning", { clear: true });
+ showNewWsModal();
+ return false;
+ }
+ navigate(
+ paths.workspace.chat(workspaces[0].slug, {
+ search: { action: "open-new-slash-command-modal" },
+ })
+ );
+ return true;
+ },
+ icon: SlashCommandIcon,
+ },
+ {
+ id: "visit_community",
+ title: t("main-page.checklist.tasks.visit_community.title"),
+ description: t("main-page.checklist.tasks.visit_community.description"),
+ action: t("main-page.checklist.tasks.visit_community.action"),
+ handler: () => {
+ window.open(paths.communityHub.website(), "_blank");
+ return true;
+ },
+ icon: UsersThree,
+ },
+];
diff --git a/frontend/src/pages/Main/Home/Checklist/index.jsx b/frontend/src/pages/Main/Home/Checklist/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..80e28557d644bef9432ec242eb86dbe851af868f
--- /dev/null
+++ b/frontend/src/pages/Main/Home/Checklist/index.jsx
@@ -0,0 +1,216 @@
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import ManageWorkspace, {
+ useManageWorkspaceModal,
+} from "@/components/Modals/ManageWorkspace";
+import NewWorkspaceModal, {
+ useNewWorkspaceModal,
+} from "@/components/Modals/NewWorkspace";
+import Workspace from "@/models/workspace";
+import { useNavigate } from "react-router-dom";
+import { ChecklistItem } from "./ChecklistItem";
+import showToast from "@/utils/toast";
+import {
+ CHECKLIST_HIDDEN,
+ CHECKLIST_STORAGE_KEY,
+ CHECKLIST_ITEMS,
+ CHECKLIST_UPDATED_EVENT,
+} from "./constants";
+import ConfettiExplosion from "react-confetti-explosion";
+import { safeJsonParse } from "@/utils/request";
+import { useTranslation } from "react-i18next";
+
+const MemoizedChecklistItem = React.memo(ChecklistItem);
+export default function Checklist() {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(true);
+ const [isHidden, setIsHidden] = useState(false);
+ const [completedCount, setCompletedCount] = useState(0);
+ const [isCompleted, setIsCompleted] = useState(false);
+ const [selectedWorkspace, setSelectedWorkspace] = useState(null);
+ const [workspaces, setWorkspaces] = useState([]);
+ const navigate = useNavigate();
+ const containerRef = useRef(null);
+ const {
+ showModal: showNewWsModal,
+ hideModal: hideNewWsModal,
+ showing: showingNewWsModal,
+ } = useNewWorkspaceModal();
+ const { showModal: showManageWsModal, hideModal: hideManageWsModal } =
+ useManageWorkspaceModal();
+
+ const createItemHandler = useCallback(
+ (item) => {
+ return () =>
+ item.handler({
+ workspaces,
+ navigate,
+ setSelectedWorkspace,
+ showManageWsModal,
+ showToast,
+ showNewWsModal,
+ });
+ },
+ [
+ workspaces,
+ navigate,
+ setSelectedWorkspace,
+ showManageWsModal,
+ showToast,
+ showNewWsModal,
+ ]
+ );
+
+ useEffect(() => {
+ async function initialize() {
+ try {
+ const hidden = window.localStorage.getItem(CHECKLIST_HIDDEN);
+ setIsHidden(!!hidden);
+ // If the checklist is hidden, don't bother evaluating it.
+ if (hidden) return;
+
+ // If the checklist is completed then dont continue and just show the completed state.
+ const checklist = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
+ const existingChecklist = checklist ? safeJsonParse(checklist, {}) : {};
+ const isCompleted =
+ Object.keys(existingChecklist).length === CHECKLIST_ITEMS().length;
+ setIsCompleted(isCompleted);
+ if (isCompleted) return;
+
+ // Otherwise, we can fetch workspaces for our checklist tasks as well
+ // as determine if the create_workspace task is completed for pre-checking.
+ const workspaces = await Workspace.all();
+ setWorkspaces(workspaces);
+ if (workspaces.length > 0) {
+ existingChecklist["create_workspace"] = true;
+ window.localStorage.setItem(
+ CHECKLIST_STORAGE_KEY,
+ JSON.stringify(existingChecklist)
+ );
+ }
+
+ evaluateChecklist(); // Evaluate checklist on mount.
+ window.addEventListener(CHECKLIST_UPDATED_EVENT, evaluateChecklist);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ initialize();
+ return () => {
+ window.removeEventListener(CHECKLIST_UPDATED_EVENT, evaluateChecklist);
+ };
+ }, []);
+
+ useEffect(() => {
+ const fetchWorkspaces = async () => {
+ const workspaces = await Workspace.all();
+ setWorkspaces(workspaces);
+ };
+ fetchWorkspaces();
+ }, []);
+
+ useEffect(() => {
+ if (isCompleted) {
+ setTimeout(() => {
+ handleClose();
+ }, 5_000);
+ }
+ }, [isCompleted]);
+
+ const evaluateChecklist = useCallback(() => {
+ try {
+ const checklist = window.localStorage.getItem(CHECKLIST_STORAGE_KEY);
+ if (!checklist) return;
+ const completedItems = safeJsonParse(checklist, {});
+ setCompletedCount(Object.keys(completedItems).length);
+ setIsCompleted(
+ Object.keys(completedItems).length === CHECKLIST_ITEMS().length
+ );
+ } catch (error) {
+ console.error(error);
+ }
+ }, []);
+
+ const handleClose = useCallback(() => {
+ window.localStorage.setItem(CHECKLIST_HIDDEN, "true");
+ if (containerRef?.current) containerRef.current.style.height = "0px";
+ }, []);
+ if (isHidden || loading) return null;
+
+ return (
+
+
+ {isCompleted && (
+
+
+
+ )}
+
+
+ {t("main-page.checklist.completed")}
+
+
+
+
+
+
+
+
+ {t("main-page.checklist.title")}
+
+ {CHECKLIST_ITEMS().length - completedCount > 0 && (
+
+ {CHECKLIST_ITEMS().length - completedCount}{" "}
+ {t("main-page.checklist.tasksLeft")}
+
+ )}
+
+
+
+
+ {t("main-page.checklist.dismiss")}
+
+
+
+
+ {CHECKLIST_ITEMS().map((item) => (
+
+ ))}
+
+
+ {showingNewWsModal &&
}
+ {selectedWorkspace && (
+
{
+ setSelectedWorkspace(null);
+ hideManageWsModal();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Main/Home/ExploreFeatures/index.jsx b/frontend/src/pages/Main/Home/ExploreFeatures/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0ae0723568b645bdab7585849d43e5d0961c37c0
--- /dev/null
+++ b/frontend/src/pages/Main/Home/ExploreFeatures/index.jsx
@@ -0,0 +1,153 @@
+import { useNavigate } from "react-router-dom";
+import paths from "@/utils/paths";
+import Workspace from "@/models/workspace";
+import { useTranslation } from "react-i18next";
+
+export default function ExploreFeatures() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const chatWithAgent = async () => {
+ const workspaces = await Workspace.all();
+ if (workspaces.length > 0) {
+ const firstWorkspace = workspaces[0];
+ navigate(
+ paths.workspace.chat(firstWorkspace.slug, {
+ search: { action: "set-agent-chat" },
+ })
+ );
+ }
+ };
+
+ const buildAgentFlow = () => navigate(paths.agents.builder());
+ const setSlashCommand = async () => {
+ const workspaces = await Workspace.all();
+ if (workspaces.length > 0) {
+ const firstWorkspace = workspaces[0];
+ navigate(
+ paths.workspace.chat(firstWorkspace.slug, {
+ search: { action: "open-new-slash-command-modal" },
+ })
+ );
+ }
+ };
+
+ const exploreSlashCommands = () => {
+ window.open(paths.communityHub.viewMoreOfType("slash-commands"), "_blank");
+ };
+
+ const setSystemPrompt = async () => {
+ const workspaces = await Workspace.all();
+ if (workspaces.length > 0) {
+ const firstWorkspace = workspaces[0];
+ navigate(
+ paths.workspace.settings.chatSettings(firstWorkspace.slug, {
+ search: { action: "focus-system-prompt" },
+ })
+ );
+ }
+ };
+
+ const managePromptVariables = () => {
+ navigate(paths.settings.systemPromptVariables());
+ };
+
+ return (
+
+
+ {t("main-page.exploreMore.title")}
+
+
+
+
+
+
+
+ );
+}
+
+function FeatureCard({
+ title,
+ description,
+ primaryAction,
+ secondaryAction,
+ onPrimaryAction,
+ onSecondaryAction,
+ isNew,
+}) {
+ return (
+
+
+
+ {title}
+
+
{description}
+
+
+
+ {primaryAction}
+
+ {secondaryAction && (
+
+ {isNew && (
+
+ New
+
+ )}
+
+ {secondaryAction}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/Main/Home/QuickLinks/index.jsx b/frontend/src/pages/Main/Home/QuickLinks/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..206ac0d6c644b2b613547c881abaae1d1ad24f80
--- /dev/null
+++ b/frontend/src/pages/Main/Home/QuickLinks/index.jsx
@@ -0,0 +1,96 @@
+import { ChatCenteredDots, FileArrowDown, Plus } from "@phosphor-icons/react";
+import { useNavigate } from "react-router-dom";
+import Workspace from "@/models/workspace";
+import paths from "@/utils/paths";
+import { useManageWorkspaceModal } from "@/components/Modals/ManageWorkspace";
+import ManageWorkspace from "@/components/Modals/ManageWorkspace";
+import { useState } from "react";
+import { useNewWorkspaceModal } from "@/components/Modals/NewWorkspace";
+import NewWorkspaceModal from "@/components/Modals/NewWorkspace";
+import showToast from "@/utils/toast";
+import { useTranslation } from "react-i18next";
+
+export default function QuickLinks() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { showModal } = useManageWorkspaceModal();
+ const [selectedWorkspace, setSelectedWorkspace] = useState(null);
+ const {
+ showing: showingNewWsModal,
+ showModal: showNewWsModal,
+ hideModal: hideNewWsModal,
+ } = useNewWorkspaceModal();
+
+ const sendChat = async () => {
+ const workspaces = await Workspace.all();
+ if (workspaces.length > 0) {
+ const firstWorkspace = workspaces[0];
+ navigate(paths.workspace.chat(firstWorkspace.slug));
+ } else {
+ showToast(t("main-page.noWorkspaceError"), "warning", {
+ clear: true,
+ });
+ showNewWsModal();
+ }
+ };
+
+ const embedDocument = async () => {
+ const workspaces = await Workspace.all();
+ if (workspaces.length > 0) {
+ const firstWorkspace = workspaces[0];
+ setSelectedWorkspace(firstWorkspace);
+ showModal();
+ } else {
+ showToast(t("main-page.noWorkspaceError"), "warning", {
+ clear: true,
+ });
+ showNewWsModal();
+ }
+ };
+
+ const createWorkspace = () => {
+ showNewWsModal();
+ };
+
+ return (
+
+
+ {t("main-page.quickLinks.title")}
+
+
+
+
+ {t("main-page.quickLinks.sendChat")}
+
+
+
+ {t("main-page.quickLinks.embedDocument")}
+
+
+
+ {t("main-page.quickLinks.createWorkspace")}
+
+
+
+ {selectedWorkspace && (
+
{
+ setSelectedWorkspace(null);
+ }}
+ />
+ )}
+
+ {showingNewWsModal && }
+
+ );
+}
diff --git a/frontend/src/pages/Main/Home/Resources/index.jsx b/frontend/src/pages/Main/Home/Resources/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f85303390caf6e906c48d54e2c1bfbee63f246bc
--- /dev/null
+++ b/frontend/src/pages/Main/Home/Resources/index.jsx
@@ -0,0 +1,48 @@
+import paths from "@/utils/paths";
+import { ArrowCircleUpRight } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+import { KEYBOARD_SHORTCUTS_HELP_EVENT } from "@/utils/keyboardShortcuts";
+
+export default function Resources() {
+ const { t } = useTranslation();
+ const showKeyboardShortcuts = () => {
+ window.dispatchEvent(
+ new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { detail: { show: true } })
+ );
+ };
+
+ return (
+
+
+ {t("main-page.resources.title")}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Main/Home/Updates/index.jsx b/frontend/src/pages/Main/Home/Updates/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..756d083ec2128fa033c5bdf817e12db3f34e30ea
--- /dev/null
+++ b/frontend/src/pages/Main/Home/Updates/index.jsx
@@ -0,0 +1,209 @@
+import { useEffect, useState } from "react";
+import { safeJsonParse } from "@/utils/request";
+import { Link } from "react-router-dom";
+import PlaceholderOne from "@/media/announcements/placeholder-1.png";
+import PlaceholderTwo from "@/media/announcements/placeholder-2.png";
+import PlaceholderThree from "@/media/announcements/placeholder-3.png";
+import { useTranslation } from "react-i18next";
+
+/**
+ * @typedef {Object} NewsItem
+ * @property {string} title
+ * @property {string|null} thumbnail_url
+ * @property {string} short_description
+ * @property {string|null} goto
+ * @property {string|null} source
+ * @property {string|null} date
+ */
+
+const NEWS_CACHE_CONFIG = {
+ articles: "https://cdn.anythingllm.com/support/announcements/list.txt",
+ announcementsDir: "https://cdn.anythingllm.com/support/announcements",
+ cacheKey: "anythingllm_announcements",
+ ttl: 7 * 24 * 60 * 60 * 1000, // 1 week
+};
+
+const PLACEHOLDERS = [PlaceholderOne, PlaceholderTwo, PlaceholderThree];
+
+function randomPlaceholder() {
+ return PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)];
+}
+
+export default function Updates() {
+ const { t } = useTranslation();
+ const { isLoading, news } = useNewsItems();
+ if (isLoading || !news?.length) return null;
+
+ return (
+
+
+ {t("main-page.announcements.title")}
+
+
+ {news.map((item, index) => (
+
+ ))}
+
+
+ );
+}
+
+function isExternal(goto) {
+ if (!goto) return false;
+ const url = new URL(goto);
+ return url.hostname !== window.location.hostname;
+}
+
+function AnnouncementCard({
+ thumbnail_url = null,
+ title = "",
+ subtitle = "",
+ author = "AnythingLLM",
+ date = null,
+ goto = "#",
+}) {
+ const placeHolderImage = randomPlaceholder();
+ const isExternalLink = isExternal(goto);
+
+ return (
+
+
+
(e.target.src = placeHolderImage)}
+ className="w-[80px] h-[80px] rounded-lg flex-shrink-0 object-cover"
+ />
+
+
{title}
+
+ {subtitle}
+
+
+ {author}
+ {date ?? "Recently"}
+
+
+
+
+ );
+}
+
+/**
+ * Get cached news from localStorage if it exists and is valid by ttl timestamp
+ * @returns {null|NewsItem[]} - Array of news items
+ */
+function getCachedNews() {
+ try {
+ const cachedNews = localStorage.getItem(NEWS_CACHE_CONFIG.cacheKey);
+ if (!cachedNews) return null;
+
+ /** @type {{news: NewsItem[]|null, timestamp: number|null}|null} */
+ const parsedNews = safeJsonParse(cachedNews, null);
+ if (!parsedNews || !parsedNews?.news?.length || !parsedNews.timestamp)
+ return null;
+
+ const now = new Date();
+ const cacheExpiration = new Date(
+ parsedNews.timestamp + NEWS_CACHE_CONFIG.ttl
+ );
+ if (now < cacheExpiration) return parsedNews.news;
+ return null;
+ } catch (error) {
+ console.error("Error fetching cached news:", error);
+ return null;
+ }
+}
+
+/**
+ * Fetch news from remote source and cache it in localStorage
+ * @returns {Promise
} - Array of news items
+ */
+async function fetchRemoteNews() {
+ try {
+ const latestArticleDateRef = await fetch(NEWS_CACHE_CONFIG.articles)
+ .then((res) => {
+ if (!res.ok)
+ throw new Error(
+ `${res.status} - Failed to fetch remote news from ${NEWS_CACHE_CONFIG.articles}`
+ );
+ return res.text();
+ })
+ .then((text) => text?.split("\n")?.shift()?.trim())
+ .catch((err) => {
+ console.error(err.message);
+ return null;
+ });
+ if (!latestArticleDateRef) return null;
+
+ const dataURL = `${NEWS_CACHE_CONFIG.announcementsDir}/${latestArticleDateRef}${latestArticleDateRef.endsWith(".json") ? "" : ".json"}`;
+ /** @type {NewsItem[]|null} */
+ const announcementData = await fetch(dataURL)
+ .then((res) => {
+ if (!res.ok)
+ throw new Error(
+ `${res.status} - Failed to fetch remote news from ${dataURL}`
+ );
+ return res.json();
+ })
+ .catch((err) => {
+ console.error(err.message);
+ return [];
+ });
+
+ if (!announcementData?.length) return null;
+ localStorage.setItem(
+ NEWS_CACHE_CONFIG.cacheKey,
+ JSON.stringify({
+ news: announcementData,
+ timestamp: Date.now(),
+ })
+ );
+
+ return announcementData;
+ } catch (error) {
+ console.error("Error fetching remote news:", error);
+ return null;
+ }
+}
+
+/**
+ * @returns {{news: NewsItem[], isLoading: boolean}}
+ */
+function useNewsItems() {
+ const [news, setNews] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ async function fetchAnnouncements() {
+ try {
+ const cachedNews = getCachedNews();
+ if (cachedNews) return setNews(cachedNews);
+
+ const remoteNews = await fetchRemoteNews();
+ if (remoteNews) return setNews(remoteNews);
+ } catch (error) {
+ console.error("Error fetching cached news:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchAnnouncements();
+ }, []);
+
+ return { news, isLoading };
+}
diff --git a/frontend/src/pages/Main/Home/index.jsx b/frontend/src/pages/Main/Home/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c0dd670f9642bc7eb19bf8dca9c9615465cdfd60
--- /dev/null
+++ b/frontend/src/pages/Main/Home/index.jsx
@@ -0,0 +1,26 @@
+import React from "react";
+import QuickLinks from "./QuickLinks";
+import ExploreFeatures from "./ExploreFeatures";
+import Updates from "./Updates";
+import Resources from "./Resources";
+import Checklist from "./Checklist";
+import { isMobile } from "react-device-detect";
+
+export default function Home() {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/Main/index.jsx b/frontend/src/pages/Main/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..12d4b9333c26fa356e258cbcb6a012af86493fb5
--- /dev/null
+++ b/frontend/src/pages/Main/index.jsx
@@ -0,0 +1,24 @@
+import React from "react";
+import PasswordModal, { usePasswordModal } from "@/components/Modals/Password";
+import { FullScreenLoader } from "@/components/Preloader";
+import Home from "./Home";
+import DefaultChatContainer from "@/components/DefaultChat";
+import { isMobile } from "react-device-detect";
+import Sidebar, { SidebarMobileHeader } from "@/components/Sidebar";
+import { userFromStorage } from "@/utils/request";
+
+export default function Main() {
+ const { loading, requiresAuth, mode } = usePasswordModal();
+
+ if (loading) return ;
+ if (requiresAuth !== false)
+ return <>{requiresAuth !== null && }>;
+
+ const user = userFromStorage();
+ return (
+
+ {!isMobile ? : }
+ {!!user && user?.role !== "admin" ? : }
+
+ );
+}
diff --git a/frontend/src/pages/OnboardingFlow/Steps/CreateWorkspace/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/CreateWorkspace/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9b93dcfe6c0b223f449dccdb014c290654e1a6e2
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/CreateWorkspace/index.jsx
@@ -0,0 +1,95 @@
+import React, { useEffect, useRef, useState } from "react";
+import illustration from "@/media/illustrations/create-workspace.png";
+import paths from "@/utils/paths";
+import showToast from "@/utils/toast";
+import { useNavigate } from "react-router-dom";
+import Workspace from "@/models/workspace";
+import { useTranslation } from "react-i18next";
+
+export default function CreateWorkspace({
+ setHeader,
+ setForwardBtn,
+ setBackBtn,
+}) {
+ const { t } = useTranslation();
+ const [workspaceName, setWorkspaceName] = useState("");
+ const navigate = useNavigate();
+ const createWorkspaceRef = useRef();
+ const TITLE = t("onboarding.workspace.title");
+ const DESCRIPTION = t("onboarding.workspace.description");
+
+ useEffect(() => {
+ setHeader({ title: TITLE, description: DESCRIPTION });
+ setBackBtn({ showing: false, disabled: false, onClick: handleBack });
+ }, []);
+
+ useEffect(() => {
+ if (workspaceName.length > 0) {
+ setForwardBtn({ showing: true, disabled: false, onClick: handleForward });
+ } else {
+ setForwardBtn({ showing: true, disabled: true, onClick: handleForward });
+ }
+ }, [workspaceName]);
+
+ const handleCreate = async (e) => {
+ e.preventDefault();
+ const form = new FormData(e.target);
+ const { workspace, error } = await Workspace.new({
+ name: form.get("name"),
+ onboardingComplete: true,
+ });
+ if (!!workspace) {
+ showToast(
+ "Workspace created successfully! Taking you to home...",
+ "success"
+ );
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ navigate(paths.home());
+ } else {
+ showToast(`Failed to create workspace: ${error}`, "error");
+ }
+ };
+
+ function handleForward() {
+ createWorkspaceRef.current.click();
+ }
+
+ function handleBack() {
+ navigate(paths.onboarding.survey());
+ }
+
+ return (
+
+
+
+ {" "}
+
+
+ {t("common.workspaces-name")}
+
+ setWorkspaceName(e.target.value)}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/OnboardingFlow/Steps/DataHandling/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/DataHandling/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..442a443d949f88ba058d289e551b3458f606cd14
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/DataHandling/index.jsx
@@ -0,0 +1,575 @@
+import PreLoader from "@/components/Preloader";
+import System from "@/models/system";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import OpenAiLogo from "@/media/llmprovider/openai.png";
+import GenericOpenAiLogo from "@/media/llmprovider/generic-openai.png";
+import AzureOpenAiLogo from "@/media/llmprovider/azure.png";
+import AnthropicLogo from "@/media/llmprovider/anthropic.png";
+import GeminiLogo from "@/media/llmprovider/gemini.png";
+import OllamaLogo from "@/media/llmprovider/ollama.png";
+import TogetherAILogo from "@/media/llmprovider/togetherai.png";
+import FireworksAILogo from "@/media/llmprovider/fireworksai.jpeg";
+import NvidiaNimLogo from "@/media/llmprovider/nvidia-nim.png";
+import LMStudioLogo from "@/media/llmprovider/lmstudio.png";
+import LocalAiLogo from "@/media/llmprovider/localai.png";
+import MistralLogo from "@/media/llmprovider/mistral.jpeg";
+import HuggingFaceLogo from "@/media/llmprovider/huggingface.png";
+import PerplexityLogo from "@/media/llmprovider/perplexity.png";
+import OpenRouterLogo from "@/media/llmprovider/openrouter.jpeg";
+import NovitaLogo from "@/media/llmprovider/novita.png";
+import GroqLogo from "@/media/llmprovider/groq.png";
+import KoboldCPPLogo from "@/media/llmprovider/koboldcpp.png";
+import TextGenWebUILogo from "@/media/llmprovider/text-generation-webui.png";
+import LiteLLMLogo from "@/media/llmprovider/litellm.png";
+import AWSBedrockLogo from "@/media/llmprovider/bedrock.png";
+import DeepSeekLogo from "@/media/llmprovider/deepseek.png";
+import APIPieLogo from "@/media/llmprovider/apipie.png";
+import XAILogo from "@/media/llmprovider/xai.png";
+import CohereLogo from "@/media/llmprovider/cohere.png";
+import ZillizLogo from "@/media/vectordbs/zilliz.png";
+import AstraDBLogo from "@/media/vectordbs/astraDB.png";
+import ChromaLogo from "@/media/vectordbs/chroma.png";
+import PineconeLogo from "@/media/vectordbs/pinecone.png";
+import LanceDbLogo from "@/media/vectordbs/lancedb.png";
+import WeaviateLogo from "@/media/vectordbs/weaviate.png";
+import QDrantLogo from "@/media/vectordbs/qdrant.png";
+import MilvusLogo from "@/media/vectordbs/milvus.png";
+import VoyageAiLogo from "@/media/embeddingprovider/voyageai.png";
+import PPIOLogo from "@/media/llmprovider/ppio.png";
+import PGVectorLogo from "@/media/vectordbs/pgvector.png";
+import DPAISLogo from "@/media/llmprovider/dpais.png";
+import MoonshotAiLogo from "@/media/llmprovider/moonshotai.png";
+import CometApiLogo from "@/media/llmprovider/cometapi.png";
+
+import React, { useState, useEffect } from "react";
+import paths from "@/utils/paths";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+export const LLM_SELECTION_PRIVACY = {
+ openai: {
+ name: "OpenAI",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to OpenAI",
+ ],
+ logo: OpenAiLogo,
+ },
+ azure: {
+ name: "Azure OpenAI",
+ description: [
+ "Your chats will not be used for training",
+ "Your text and embedding text are not visible to OpenAI or Microsoft",
+ ],
+ logo: AzureOpenAiLogo,
+ },
+ anthropic: {
+ name: "Anthropic",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to Anthropic",
+ ],
+ logo: AnthropicLogo,
+ },
+ gemini: {
+ name: "Google Gemini",
+ description: [
+ "Your chats are de-identified and used in training",
+ "Your prompts and document text used in response creation are visible to Google",
+ ],
+ logo: GeminiLogo,
+ },
+ "nvidia-nim": {
+ name: "NVIDIA NIM",
+ description: [
+ "Your model and chats are only accessible on the machine running the NVIDIA NIM",
+ ],
+ logo: NvidiaNimLogo,
+ },
+ lmstudio: {
+ name: "LMStudio",
+ description: [
+ "Your model and chats are only accessible on the server running LMStudio",
+ ],
+ logo: LMStudioLogo,
+ },
+ localai: {
+ name: "LocalAI",
+ description: [
+ "Your model and chats are only accessible on the server running LocalAI",
+ ],
+ logo: LocalAiLogo,
+ },
+ ollama: {
+ name: "Ollama",
+ description: [
+ "Your model and chats are only accessible on the machine running Ollama models",
+ ],
+ logo: OllamaLogo,
+ },
+ togetherai: {
+ name: "TogetherAI",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to TogetherAI",
+ ],
+ logo: TogetherAILogo,
+ },
+ fireworksai: {
+ name: "FireworksAI",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to Fireworks AI",
+ ],
+ logo: FireworksAILogo,
+ },
+ mistral: {
+ name: "Mistral",
+ description: [
+ "Your prompts and document text used in response creation are visible to Mistral",
+ ],
+ logo: MistralLogo,
+ },
+ huggingface: {
+ name: "HuggingFace",
+ description: [
+ "Your prompts and document text used in response are sent to your HuggingFace managed endpoint",
+ ],
+ logo: HuggingFaceLogo,
+ },
+ perplexity: {
+ name: "Perplexity AI",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to Perplexity AI",
+ ],
+ logo: PerplexityLogo,
+ },
+ openrouter: {
+ name: "OpenRouter",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to OpenRouter",
+ ],
+ logo: OpenRouterLogo,
+ },
+ novita: {
+ name: "Novita AI",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to Novita AI",
+ ],
+ logo: NovitaLogo,
+ },
+ groq: {
+ name: "Groq",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to Groq",
+ ],
+ logo: GroqLogo,
+ },
+ koboldcpp: {
+ name: "KoboldCPP",
+ description: [
+ "Your model and chats are only accessible on the server running KoboldCPP",
+ ],
+ logo: KoboldCPPLogo,
+ },
+ textgenwebui: {
+ name: "Oobabooga Web UI",
+ description: [
+ "Your model and chats are only accessible on the server running the Oobabooga Text Generation Web UI",
+ ],
+ logo: TextGenWebUILogo,
+ },
+ "generic-openai": {
+ name: "Generic OpenAI compatible service",
+ description: [
+ "Data is shared according to the terms of service applicable with your generic endpoint provider.",
+ ],
+ logo: GenericOpenAiLogo,
+ },
+ cohere: {
+ name: "Cohere",
+ description: [
+ "Data is shared according to the terms of service of cohere.com and your localities privacy laws.",
+ ],
+ logo: CohereLogo,
+ },
+ litellm: {
+ name: "LiteLLM",
+ description: [
+ "Your model and chats are only accessible on the server running LiteLLM",
+ ],
+ logo: LiteLLMLogo,
+ },
+ bedrock: {
+ name: "AWS Bedrock",
+ description: [
+ "You model and chat contents are subject to the agreed EULA for AWS and the model provider on aws.amazon.com",
+ ],
+ logo: AWSBedrockLogo,
+ },
+ deepseek: {
+ name: "DeepSeek",
+ description: ["Your model and chat contents are visible to DeepSeek"],
+ logo: DeepSeekLogo,
+ },
+ apipie: {
+ name: "APIpie.AI",
+ description: [
+ "Your model and chat contents are visible to APIpie in accordance with their terms of service.",
+ ],
+ logo: APIPieLogo,
+ },
+ xai: {
+ name: "xAI",
+ description: [
+ "Your model and chat contents are visible to xAI in accordance with their terms of service.",
+ ],
+ logo: XAILogo,
+ },
+ ppio: {
+ name: "PPIO",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to PPIO",
+ ],
+ logo: PPIOLogo,
+ },
+ dpais: {
+ name: "Dell Pro AI Studio",
+ description: [
+ "Your model and chat contents are only accessible on the computer running Dell Pro AI Studio",
+ ],
+ logo: DPAISLogo,
+ },
+ moonshotai: {
+ name: "Moonshot AI",
+ description: [
+ "Your chats may be used by Moonshot AI for training and model refinement",
+ "Your prompts and document text used in response creation are visible to Moonshot AI",
+ ],
+ logo: MoonshotAiLogo,
+ },
+ cometapi: {
+ name: "CometAPI",
+ description: [
+ "Your chats will not be used for training",
+ "Your prompts and document text used in response creation are visible to CometAPI",
+ ],
+ logo: CometApiLogo,
+ },
+};
+
+export const VECTOR_DB_PRIVACY = {
+ pgvector: {
+ name: "PGVector",
+ description: [
+ "Your vectors and document text are stored on your PostgreSQL instance",
+ "Access to your instance is managed by you",
+ ],
+ logo: PGVectorLogo,
+ },
+ chroma: {
+ name: "Chroma",
+ description: [
+ "Your vectors and document text are stored on your Chroma instance",
+ "Access to your instance is managed by you",
+ ],
+ logo: ChromaLogo,
+ },
+ chromacloud: {
+ name: "Chroma Cloud",
+ description: [
+ "Your vectors and document text are stored on Chroma's cloud service",
+ "Access to your data is managed by Chroma",
+ ],
+ logo: ChromaLogo,
+ },
+ pinecone: {
+ name: "Pinecone",
+ description: [
+ "Your vectors and document text are stored on Pinecone's servers",
+ "Access to your data is managed by Pinecone",
+ ],
+ logo: PineconeLogo,
+ },
+ qdrant: {
+ name: "Qdrant",
+ description: [
+ "Your vectors and document text are stored on your Qdrant instance (cloud or self-hosted)",
+ ],
+ logo: QDrantLogo,
+ },
+ weaviate: {
+ name: "Weaviate",
+ description: [
+ "Your vectors and document text are stored on your Weaviate instance (cloud or self-hosted)",
+ ],
+ logo: WeaviateLogo,
+ },
+ milvus: {
+ name: "Milvus",
+ description: [
+ "Your vectors and document text are stored on your Milvus instance (cloud or self-hosted)",
+ ],
+ logo: MilvusLogo,
+ },
+ zilliz: {
+ name: "Zilliz Cloud",
+ description: [
+ "Your vectors and document text are stored on your Zilliz cloud cluster.",
+ ],
+ logo: ZillizLogo,
+ },
+ astra: {
+ name: "AstraDB",
+ description: [
+ "Your vectors and document text are stored on your cloud AstraDB database.",
+ ],
+ logo: AstraDBLogo,
+ },
+ lancedb: {
+ name: "LanceDB",
+ description: [
+ "Your vectors and document text are stored privately on this instance of AnythingLLM",
+ ],
+ logo: LanceDbLogo,
+ },
+};
+
+export const EMBEDDING_ENGINE_PRIVACY = {
+ native: {
+ name: "AnythingLLM Embedder",
+ description: [
+ "Your document text is embedded privately on this instance of AnythingLLM",
+ ],
+ logo: AnythingLLMIcon,
+ },
+ openai: {
+ name: "OpenAI",
+ description: [
+ "Your document text is sent to OpenAI servers",
+ "Your documents are not used for training",
+ ],
+ logo: OpenAiLogo,
+ },
+ azure: {
+ name: "Azure OpenAI",
+ description: [
+ "Your document text is sent to your Microsoft Azure service",
+ "Your documents are not used for training",
+ ],
+ logo: AzureOpenAiLogo,
+ },
+ localai: {
+ name: "LocalAI",
+ description: [
+ "Your document text is embedded privately on the server running LocalAI",
+ ],
+ logo: LocalAiLogo,
+ },
+ ollama: {
+ name: "Ollama",
+ description: [
+ "Your document text is embedded privately on the server running Ollama",
+ ],
+ logo: OllamaLogo,
+ },
+ lmstudio: {
+ name: "LMStudio",
+ description: [
+ "Your document text is embedded privately on the server running LMStudio",
+ ],
+ logo: LMStudioLogo,
+ },
+ cohere: {
+ name: "Cohere",
+ description: [
+ "Data is shared according to the terms of service of cohere.com and your localities privacy laws.",
+ ],
+ logo: CohereLogo,
+ },
+ voyageai: {
+ name: "Voyage AI",
+ description: [
+ "Data sent to Voyage AI's servers is shared according to the terms of service of voyageai.com.",
+ ],
+ logo: VoyageAiLogo,
+ },
+ mistral: {
+ name: "Mistral AI",
+ description: [
+ "Data sent to Mistral AI's servers is shared according to the terms of service of https://mistral.ai.",
+ ],
+ logo: MistralLogo,
+ },
+ litellm: {
+ name: "LiteLLM",
+ description: [
+ "Your document text is only accessible on the server running LiteLLM and to the providers you configured in LiteLLM.",
+ ],
+ logo: LiteLLMLogo,
+ },
+ "generic-openai": {
+ name: "Generic OpenAI compatible service",
+ description: [
+ "Data is shared according to the terms of service applicable with your generic endpoint provider.",
+ ],
+ logo: GenericOpenAiLogo,
+ },
+ gemini: {
+ name: "Google Gemini",
+ description: [
+ "Your document text is sent to Google Gemini's servers for processing",
+ "Your document text is stored or managed according to the terms of service of Google Gemini API Terms of Service",
+ ],
+ logo: GeminiLogo,
+ },
+};
+
+export const FALLBACKS = {
+ LLM: (provider) => ({
+ name: "Unknown",
+ description: [
+ `"${provider}" has no known data handling policy defined in AnythingLLM`,
+ ],
+ logo: AnythingLLMIcon,
+ }),
+ EMBEDDING: (provider) => ({
+ name: "Unknown",
+ description: [
+ `"${provider}" has no known data handling policy defined in AnythingLLM`,
+ ],
+ logo: AnythingLLMIcon,
+ }),
+ VECTOR: (provider) => ({
+ name: "Unknown",
+ description: [
+ `"${provider}" has no known data handling policy defined in AnythingLLM`,
+ ],
+ logo: AnythingLLMIcon,
+ }),
+};
+
+export default function DataHandling({ setHeader, setForwardBtn, setBackBtn }) {
+ const { t } = useTranslation();
+ const [llmChoice, setLLMChoice] = useState("openai");
+ const [loading, setLoading] = useState(true);
+ const [vectorDb, setVectorDb] = useState("pinecone");
+ const [embeddingEngine, setEmbeddingEngine] = useState("openai");
+ const navigate = useNavigate();
+
+ const TITLE = t("onboarding.data.title");
+ const DESCRIPTION = t("onboarding.data.description");
+
+ useEffect(() => {
+ setHeader({ title: TITLE, description: DESCRIPTION });
+ setForwardBtn({ showing: true, disabled: false, onClick: handleForward });
+ setBackBtn({ showing: false, disabled: false, onClick: handleBack });
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setLLMChoice(_settings?.LLMProvider || "openai");
+ setVectorDb(_settings?.VectorDB || "lancedb");
+ setEmbeddingEngine(_settings?.EmbeddingEngine || "openai");
+
+ setLoading(false);
+ }
+ fetchKeys();
+ }, []);
+
+ function handleForward() {
+ navigate(paths.onboarding.survey());
+ }
+
+ function handleBack() {
+ navigate(paths.onboarding.userSetup());
+ }
+
+ if (loading)
+ return (
+
+ );
+
+ const LLMSelection =
+ LLM_SELECTION_PRIVACY?.[llmChoice] || FALLBACKS.LLM(llmChoice);
+ const EmbeddingEngine =
+ EMBEDDING_ENGINE_PRIVACY?.[embeddingEngine] ||
+ FALLBACKS.EMBEDDING(embeddingEngine);
+ const VectorDb = VECTOR_DB_PRIVACY?.[vectorDb] || FALLBACKS.VECTOR(vectorDb);
+
+ return (
+
+
+
+
+ LLM Selection
+
+
+
+
+ {LLMSelection.name}
+
+
+
+ {LLMSelection.description.map((desc) => (
+ {desc}
+ ))}
+
+
+
+
+ Embedding Preference
+
+
+
+
+ {EmbeddingEngine.name}
+
+
+
+ {EmbeddingEngine.description.map((desc) => (
+ {desc}
+ ))}
+
+
+
+
+
+ Vector Database
+
+
+
+
+ {VectorDb.name}
+
+
+
+ {VectorDb.description.map((desc) => (
+ {desc}
+ ))}
+
+
+
+
+ {t("onboarding.data.settingsHint")}
+
+
+ );
+}
diff --git a/frontend/src/pages/OnboardingFlow/Steps/Home/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/Home/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..48e3d21cc51029bd3956d1d031ec5734e863bb3b
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/Home/index.jsx
@@ -0,0 +1,62 @@
+import paths from "@/utils/paths";
+import LGroupImg from "./l_group.png";
+import RGroupImg from "./r_group.png";
+import LGroupImgLight from "./l_group-light.png";
+import RGroupImgLight from "./r_group-light.png";
+import AnythingLLMLogo from "@/media/logo/anything-llm.png";
+import { useNavigate } from "react-router-dom";
+import { useTheme } from "@/hooks/useTheme";
+import { useTranslation } from "react-i18next";
+
+const IMG_SRCSET = {
+ light: {
+ l: LGroupImgLight,
+ r: RGroupImgLight,
+ },
+ default: {
+ l: LGroupImg,
+ r: RGroupImg,
+ },
+};
+
+export default function OnboardingHome() {
+ const navigate = useNavigate();
+ const { theme } = useTheme();
+ const { t } = useTranslation();
+ const srcSet = IMG_SRCSET?.[theme] || IMG_SRCSET.default;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {t("onboarding.home.title")}
+
+
+
navigate(paths.onboarding.llmPreference())}
+ className="border-[2px] border-theme-text-primary animate-pulse light:animate-none w-full md:max-w-[350px] md:min-w-[300px] text-center py-3 bg-theme-button-primary hover:bg-theme-bg-secondary text-theme-text-primary font-semibold text-sm my-10 rounded-md "
+ >
+ {t("onboarding.home.getStarted")}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/OnboardingFlow/Steps/Home/l_group-light.png b/frontend/src/pages/OnboardingFlow/Steps/Home/l_group-light.png
new file mode 100644
index 0000000000000000000000000000000000000000..95a6f8ccbba955abb8155a229a02ab8caa1803ef
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/Home/l_group-light.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e9fc4f0b0388f9435a2061ce5a9a925703e353926bedaafa06aab33162375403
+size 47306
diff --git a/frontend/src/pages/OnboardingFlow/Steps/Home/l_group.png b/frontend/src/pages/OnboardingFlow/Steps/Home/l_group.png
new file mode 100644
index 0000000000000000000000000000000000000000..3efa5befe96832b74678fe411e992753d67717cb
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/Home/l_group.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9cf87a50c4a6a5a279364c240324c7627ee8b082764feb644c3fb99f2df7d3dd
+size 64373
diff --git a/frontend/src/pages/OnboardingFlow/Steps/Home/r_group-light.png b/frontend/src/pages/OnboardingFlow/Steps/Home/r_group-light.png
new file mode 100644
index 0000000000000000000000000000000000000000..1b9e9097b955ed9b8784c8ae598e3fc2c10c16c1
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/Home/r_group-light.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:60d90dacfeaca204590ead5683852ae2fab9bba6df53e85ad6c9bd15dabe12ff
+size 49667
diff --git a/frontend/src/pages/OnboardingFlow/Steps/Home/r_group.png b/frontend/src/pages/OnboardingFlow/Steps/Home/r_group.png
new file mode 100644
index 0000000000000000000000000000000000000000..ab0ede9c8cd8f855decf3a4248c17d89be7b0872
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/Home/r_group.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e972200a6080e7de0eb34d981d7ea9f94fc0dcc8404ec9386eafbe43c7c03baa
+size 66835
diff --git a/frontend/src/pages/OnboardingFlow/Steps/LLMPreference/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/LLMPreference/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7a16985fe111bb656012e6b3bfb75d3dfed21696
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/LLMPreference/index.jsx
@@ -0,0 +1,408 @@
+import { MagnifyingGlass } from "@phosphor-icons/react";
+import { useEffect, useState, useRef } from "react";
+import OpenAiLogo from "@/media/llmprovider/openai.png";
+import GenericOpenAiLogo from "@/media/llmprovider/generic-openai.png";
+import AzureOpenAiLogo from "@/media/llmprovider/azure.png";
+import AnthropicLogo from "@/media/llmprovider/anthropic.png";
+import GeminiLogo from "@/media/llmprovider/gemini.png";
+import OllamaLogo from "@/media/llmprovider/ollama.png";
+import LMStudioLogo from "@/media/llmprovider/lmstudio.png";
+import LocalAiLogo from "@/media/llmprovider/localai.png";
+import TogetherAILogo from "@/media/llmprovider/togetherai.png";
+import FireworksAILogo from "@/media/llmprovider/fireworksai.jpeg";
+import MistralLogo from "@/media/llmprovider/mistral.jpeg";
+import HuggingFaceLogo from "@/media/llmprovider/huggingface.png";
+import PerplexityLogo from "@/media/llmprovider/perplexity.png";
+import OpenRouterLogo from "@/media/llmprovider/openrouter.jpeg";
+import GroqLogo from "@/media/llmprovider/groq.png";
+import KoboldCPPLogo from "@/media/llmprovider/koboldcpp.png";
+import TextGenWebUILogo from "@/media/llmprovider/text-generation-webui.png";
+import LiteLLMLogo from "@/media/llmprovider/litellm.png";
+import AWSBedrockLogo from "@/media/llmprovider/bedrock.png";
+import DeepSeekLogo from "@/media/llmprovider/deepseek.png";
+import APIPieLogo from "@/media/llmprovider/apipie.png";
+import NovitaLogo from "@/media/llmprovider/novita.png";
+import XAILogo from "@/media/llmprovider/xai.png";
+import NvidiaNimLogo from "@/media/llmprovider/nvidia-nim.png";
+import CohereLogo from "@/media/llmprovider/cohere.png";
+import PPIOLogo from "@/media/llmprovider/ppio.png";
+import DellProAiStudioLogo from "@/media/llmprovider/dpais.png";
+import MoonshotAiLogo from "@/media/llmprovider/moonshotai.png";
+import CometApiLogo from "@/media/llmprovider/cometapi.png";
+
+import OpenAiOptions from "@/components/LLMSelection/OpenAiOptions";
+import GenericOpenAiOptions from "@/components/LLMSelection/GenericOpenAiOptions";
+import AzureAiOptions from "@/components/LLMSelection/AzureAiOptions";
+import AnthropicAiOptions from "@/components/LLMSelection/AnthropicAiOptions";
+import LMStudioOptions from "@/components/LLMSelection/LMStudioOptions";
+import LocalAiOptions from "@/components/LLMSelection/LocalAiOptions";
+import GeminiLLMOptions from "@/components/LLMSelection/GeminiLLMOptions";
+import OllamaLLMOptions from "@/components/LLMSelection/OllamaLLMOptions";
+import MistralOptions from "@/components/LLMSelection/MistralOptions";
+import HuggingFaceOptions from "@/components/LLMSelection/HuggingFaceOptions";
+import TogetherAiOptions from "@/components/LLMSelection/TogetherAiOptions";
+import FireworksAiOptions from "@/components/LLMSelection/FireworksAiOptions";
+import PerplexityOptions from "@/components/LLMSelection/PerplexityOptions";
+import OpenRouterOptions from "@/components/LLMSelection/OpenRouterOptions";
+import GroqAiOptions from "@/components/LLMSelection/GroqAiOptions";
+import CohereAiOptions from "@/components/LLMSelection/CohereAiOptions";
+import KoboldCPPOptions from "@/components/LLMSelection/KoboldCPPOptions";
+import TextGenWebUIOptions from "@/components/LLMSelection/TextGenWebUIOptions";
+import LiteLLMOptions from "@/components/LLMSelection/LiteLLMOptions";
+import AWSBedrockLLMOptions from "@/components/LLMSelection/AwsBedrockLLMOptions";
+import DeepSeekOptions from "@/components/LLMSelection/DeepSeekOptions";
+import ApiPieLLMOptions from "@/components/LLMSelection/ApiPieOptions";
+import NovitaLLMOptions from "@/components/LLMSelection/NovitaLLMOptions";
+import XAILLMOptions from "@/components/LLMSelection/XAiLLMOptions";
+import NvidiaNimOptions from "@/components/LLMSelection/NvidiaNimOptions";
+import PPIOLLMOptions from "@/components/LLMSelection/PPIOLLMOptions";
+import DellProAiStudioOptions from "@/components/LLMSelection/DPAISOptions";
+import MoonshotAiOptions from "@/components/LLMSelection/MoonshotAiOptions";
+import CometApiLLMOptions from "@/components/LLMSelection/CometApiLLMOptions";
+
+import LLMItem from "@/components/LLMSelection/LLMItem";
+import System from "@/models/system";
+import paths from "@/utils/paths";
+import showToast from "@/utils/toast";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+const LLMS = [
+ {
+ name: "OpenAI",
+ value: "openai",
+ logo: OpenAiLogo,
+ options: (settings) => ,
+ description: "The standard option for most non-commercial use.",
+ },
+ {
+ name: "Azure OpenAI",
+ value: "azure",
+ logo: AzureOpenAiLogo,
+ options: (settings) => ,
+ description: "The enterprise option of OpenAI hosted on Azure services.",
+ },
+ {
+ name: "Anthropic",
+ value: "anthropic",
+ logo: AnthropicLogo,
+ options: (settings) => ,
+ description: "A friendly AI Assistant hosted by Anthropic.",
+ },
+ {
+ name: "Gemini",
+ value: "gemini",
+ logo: GeminiLogo,
+ options: (settings) => ,
+ description: "Google's largest and most capable AI model",
+ },
+ {
+ name: "NVIDIA NIM",
+ value: "nvidia-nim",
+ logo: NvidiaNimLogo,
+ options: (settings) => ,
+ description:
+ "Run full parameter LLMs directly on your NVIDIA RTX GPU using NVIDIA NIM.",
+ },
+ {
+ name: "HuggingFace",
+ value: "huggingface",
+ logo: HuggingFaceLogo,
+ options: (settings) => ,
+ description:
+ "Access 150,000+ open-source LLMs and the world's AI community",
+ },
+ {
+ name: "Ollama",
+ value: "ollama",
+ logo: OllamaLogo,
+ options: (settings) => ,
+ description: "Run LLMs locally on your own machine.",
+ },
+ {
+ name: "Dell Pro AI Studio",
+ value: "dpais",
+ logo: DellProAiStudioLogo,
+ options: (settings) => ,
+ description:
+ "Run powerful LLMs quickly on NPU powered by Dell Pro AI Studio.",
+ },
+ {
+ name: "LM Studio",
+ value: "lmstudio",
+ logo: LMStudioLogo,
+ options: (settings) => ,
+ description:
+ "Discover, download, and run thousands of cutting edge LLMs in a few clicks.",
+ },
+ {
+ name: "Local AI",
+ value: "localai",
+ logo: LocalAiLogo,
+ options: (settings) => ,
+ description: "Run LLMs locally on your own machine.",
+ },
+ {
+ name: "Novita AI",
+ value: "novita",
+ logo: NovitaLogo,
+ options: (settings) => ,
+ description:
+ "Reliable, Scalable, and Cost-Effective for LLMs from Novita AI",
+ },
+ {
+ name: "KoboldCPP",
+ value: "koboldcpp",
+ logo: KoboldCPPLogo,
+ options: (settings) => ,
+ description: "Run local LLMs using koboldcpp.",
+ },
+ {
+ name: "Oobabooga Web UI",
+ value: "textgenwebui",
+ logo: TextGenWebUILogo,
+ options: (settings) => ,
+ description: "Run local LLMs using Oobabooga's Text Generation Web UI.",
+ },
+ {
+ name: "Together AI",
+ value: "togetherai",
+ logo: TogetherAILogo,
+ options: (settings) => ,
+ description: "Run open source models from Together AI.",
+ },
+ {
+ name: "Fireworks AI",
+ value: "fireworksai",
+ logo: FireworksAILogo,
+ options: (settings) => ,
+ description:
+ "The fastest and most efficient inference engine to build production-ready, compound AI systems.",
+ },
+ {
+ name: "Mistral",
+ value: "mistral",
+ logo: MistralLogo,
+ options: (settings) => ,
+ description: "Run open source models from Mistral AI.",
+ },
+ {
+ name: "Perplexity AI",
+ value: "perplexity",
+ logo: PerplexityLogo,
+ options: (settings) => ,
+ description:
+ "Run powerful and internet-connected models hosted by Perplexity AI.",
+ },
+ {
+ name: "OpenRouter",
+ value: "openrouter",
+ logo: OpenRouterLogo,
+ options: (settings) => ,
+ description: "A unified interface for LLMs.",
+ },
+ {
+ name: "Groq",
+ value: "groq",
+ logo: GroqLogo,
+ options: (settings) => ,
+ description:
+ "The fastest LLM inferencing available for real-time AI applications.",
+ },
+ {
+ name: "Cohere",
+ value: "cohere",
+ logo: CohereLogo,
+ options: (settings) => ,
+ description: "Run Cohere's powerful Command models.",
+ },
+ {
+ name: "LiteLLM",
+ value: "litellm",
+ logo: LiteLLMLogo,
+ options: (settings) => ,
+ description: "Run LiteLLM's OpenAI compatible proxy for various LLMs.",
+ },
+ {
+ name: "DeepSeek",
+ value: "deepseek",
+ logo: DeepSeekLogo,
+ options: (settings) => ,
+ description: "Run DeepSeek's powerful LLMs.",
+ },
+ {
+ name: "PPIO",
+ value: "ppio",
+ logo: PPIOLogo,
+ options: (settings) => ,
+ description:
+ "Run stable and cost-efficient open-source LLM APIs, such as DeepSeek, Llama, Qwen etc.",
+ },
+ {
+ name: "APIpie",
+ value: "apipie",
+ logo: APIPieLogo,
+ options: (settings) => ,
+ description: "A unified API of AI services from leading providers",
+ },
+ {
+ name: "Generic OpenAI",
+ value: "generic-openai",
+ logo: GenericOpenAiLogo,
+ options: (settings) => ,
+ description:
+ "Connect to any OpenAi-compatible service via a custom configuration",
+ },
+ {
+ name: "AWS Bedrock",
+ value: "bedrock",
+ logo: AWSBedrockLogo,
+ options: (settings) => ,
+ description: "Run powerful foundation models privately with AWS Bedrock.",
+ },
+ {
+ name: "xAI",
+ value: "xai",
+ logo: XAILogo,
+ options: (settings) => ,
+ description: "Run xAI's powerful LLMs like Grok-2 and more.",
+ },
+ {
+ name: "Moonshot AI",
+ value: "moonshotai",
+ logo: MoonshotAiLogo,
+ options: (settings) => ,
+ description: "Run Moonshot AI's powerful LLMs.",
+ },
+ {
+ name: "CometAPI",
+ value: "cometapi",
+ logo: CometApiLogo,
+ options: (settings) => ,
+ description: "500+ AI Models all in one API.",
+ },
+];
+
+export default function LLMPreference({
+ setHeader,
+ setForwardBtn,
+ setBackBtn,
+}) {
+ const { t } = useTranslation();
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filteredLLMs, setFilteredLLMs] = useState([]);
+ const [selectedLLM, setSelectedLLM] = useState(null);
+ const [settings, setSettings] = useState(null);
+ const formRef = useRef(null);
+ const hiddenSubmitButtonRef = useRef(null);
+ const isHosted = window.location.hostname.includes("useanything.com");
+ const navigate = useNavigate();
+
+ const TITLE = t("onboarding.llm.title");
+ const DESCRIPTION = t("onboarding.llm.description");
+
+ useEffect(() => {
+ async function fetchKeys() {
+ const _settings = await System.keys();
+ setSettings(_settings);
+ setSelectedLLM(_settings?.LLMProvider || "openai");
+ }
+ fetchKeys();
+ }, []);
+
+ function handleForward() {
+ if (hiddenSubmitButtonRef.current) {
+ hiddenSubmitButtonRef.current.click();
+ }
+ }
+
+ function handleBack() {
+ navigate(paths.onboarding.home());
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const data = {};
+ const formData = new FormData(form);
+ data.LLMProvider = selectedLLM;
+ // Default to AnythingLLM embedder and LanceDB
+ data.EmbeddingEngine = "native";
+ data.VectorDB = "lancedb";
+ for (var [key, value] of formData.entries()) data[key] = value;
+
+ const { error } = await System.updateSystem(data);
+ if (error) {
+ showToast(`Failed to save LLM settings: ${error}`, "error");
+ return;
+ }
+ navigate(paths.onboarding.userSetup());
+ };
+
+ useEffect(() => {
+ setHeader({ title: TITLE, description: DESCRIPTION });
+ setForwardBtn({ showing: true, disabled: false, onClick: handleForward });
+ setBackBtn({ showing: true, disabled: false, onClick: handleBack });
+ }, []);
+
+ useEffect(() => {
+ const filtered = LLMS.filter((llm) =>
+ llm.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredLLMs(filtered);
+ }, [searchQuery, selectedLLM]);
+
+ return (
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ autoComplete="off"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredLLMs.map((llm) => {
+ if (llm.value === "native" && isHosted) return null;
+ return (
+ setSelectedLLM(llm.value)}
+ />
+ );
+ })}
+
+
+
+ {selectedLLM &&
+ LLMS.find((llm) => llm.value === selectedLLM)?.options(settings)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/OnboardingFlow/Steps/Survey/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/Survey/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..40dfcb9cf19e118bdf8398f367926ad006ae57f3
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/Survey/index.jsx
@@ -0,0 +1,272 @@
+import {
+ COMPLETE_QUESTIONNAIRE,
+ ONBOARDING_SURVEY_URL,
+} from "@/utils/constants";
+import paths from "@/utils/paths";
+import { CheckCircle } from "@phosphor-icons/react";
+import React, { useState, useEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+async function sendQuestionnaire({ email, useCase, comment }) {
+ if (import.meta.env.DEV) {
+ console.log("sendQuestionnaire", { email, useCase, comment });
+ return;
+ }
+
+ const data = JSON.stringify({
+ email,
+ useCase,
+ comment,
+ sourceId: "0VRjqHh6Vukqi0x0Vd0n/m8JuT7k8nOz",
+ });
+
+ if (!navigator.sendBeacon) {
+ console.log("navigator.sendBeacon not supported, falling back to fetch");
+ return fetch(ONBOARDING_SURVEY_URL, {
+ method: "POST",
+ body: data,
+ })
+ .then(() => {
+ window.localStorage.setItem(COMPLETE_QUESTIONNAIRE, true);
+ console.log(`✅ Questionnaire responses sent.`);
+ })
+ .catch((error) => {
+ console.error(`sendQuestionnaire`, error.message);
+ });
+ }
+
+ navigator.sendBeacon(ONBOARDING_SURVEY_URL, data);
+ window.localStorage.setItem(COMPLETE_QUESTIONNAIRE, true);
+ console.log(`✅ Questionnaire responses sent.`);
+}
+
+export default function Survey({ setHeader, setForwardBtn, setBackBtn }) {
+ const { t } = useTranslation();
+ const [selectedOption, setSelectedOption] = useState("");
+ const formRef = useRef(null);
+ const navigate = useNavigate();
+ const submitRef = useRef(null);
+
+ const TITLE = t("onboarding.survey.title");
+ const DESCRIPTION = t("onboarding.survey.description");
+
+ function handleForward() {
+ if (!!window?.localStorage?.getItem(COMPLETE_QUESTIONNAIRE)) {
+ navigate(paths.onboarding.createWorkspace());
+ return;
+ }
+
+ if (!formRef.current) {
+ skipSurvey();
+ return;
+ }
+
+ // Check if any inputs are not empty. If that is the case, trigger form validation.
+ // via the requestSubmit() handler
+ const formData = new FormData(formRef.current);
+ if (
+ !!formData.get("email") ||
+ !!formData.get("use_case") ||
+ !!formData.get("comment")
+ ) {
+ formRef.current.requestSubmit();
+ return;
+ }
+
+ skipSurvey();
+ }
+
+ function skipSurvey() {
+ navigate(paths.onboarding.createWorkspace());
+ }
+
+ function handleBack() {
+ navigate(paths.onboarding.dataHandling());
+ }
+
+ useEffect(() => {
+ setHeader({ title: TITLE, description: DESCRIPTION });
+ setForwardBtn({ showing: true, disabled: false, onClick: handleForward });
+ setBackBtn({ showing: true, disabled: false, onClick: handleBack });
+ }, []);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const formData = new FormData(form);
+
+ await sendQuestionnaire({
+ email: formData.get("email"),
+ useCase: formData.get("use_case") || "other",
+ comment: formData.get("comment") || null,
+ });
+
+ navigate(paths.onboarding.createWorkspace());
+ };
+
+ if (!!window?.localStorage?.getItem(COMPLETE_QUESTIONNAIRE)) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t("onboarding.survey.email")}{" "}
+
+
+
+
+
+
+ {t("onboarding.survey.useCase")}{" "}
+
+
+
+ setSelectedOption(e.target.value)}
+ className="hidden"
+ />
+
+
+ {t("onboarding.survey.useCaseWork")}
+
+
+
+ setSelectedOption(e.target.value)}
+ className="hidden"
+ />
+
+
+ {t("onboarding.survey.useCasePersonal")}
+
+
+
+ setSelectedOption(e.target.value)}
+ className="hidden"
+ />
+
+
+ {t("onboarding.survey.useCaseOther")}
+
+
+
+
+
+
+
+ {t("onboarding.survey.comment")}{" "}
+
+ ({t("common.optional")})
+
+
+
+
+
+
+
+
+ {t("onboarding.survey.skip")}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/OnboardingFlow/Steps/UserSetup/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/UserSetup/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..1c9ad51bb1e76a832de4aabfeba30d81c041ee03
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/UserSetup/index.jsx
@@ -0,0 +1,341 @@
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import React, { useState, useEffect, useRef } from "react";
+import debounce from "lodash.debounce";
+import paths from "@/utils/paths";
+import { useNavigate } from "react-router-dom";
+import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
+import { useTranslation } from "react-i18next";
+
+export default function UserSetup({ setHeader, setForwardBtn, setBackBtn }) {
+ const { t } = useTranslation();
+ const [selectedOption, setSelectedOption] = useState("");
+ const [singleUserPasswordValid, setSingleUserPasswordValid] = useState(false);
+ const [multiUserLoginValid, setMultiUserLoginValid] = useState(false);
+ const [enablePassword, setEnablePassword] = useState(false);
+ const myTeamSubmitRef = useRef(null);
+ const justMeSubmitRef = useRef(null);
+ const navigate = useNavigate();
+
+ const TITLE = t("onboarding.userSetup.title");
+ const DESCRIPTION = t("onboarding.userSetup.description");
+
+ function handleForward() {
+ if (selectedOption === "just_me" && enablePassword) {
+ justMeSubmitRef.current?.click();
+ } else if (selectedOption === "just_me" && !enablePassword) {
+ navigate(paths.onboarding.dataHandling());
+ } else if (selectedOption === "my_team") {
+ myTeamSubmitRef.current?.click();
+ }
+ }
+
+ function handleBack() {
+ navigate(paths.onboarding.llmPreference());
+ }
+
+ useEffect(() => {
+ let isDisabled = true;
+ if (selectedOption === "just_me") {
+ isDisabled = !singleUserPasswordValid;
+ } else if (selectedOption === "my_team") {
+ isDisabled = !multiUserLoginValid;
+ }
+
+ setForwardBtn({
+ showing: true,
+ disabled: isDisabled,
+ onClick: handleForward,
+ });
+ }, [selectedOption, singleUserPasswordValid, multiUserLoginValid]);
+
+ useEffect(() => {
+ setHeader({ title: TITLE, description: DESCRIPTION });
+ setBackBtn({ showing: true, disabled: false, onClick: handleBack });
+ }, []);
+
+ return (
+
+
+
+ {t("onboarding.userSetup.howManyUsers")}
+
+
+
setSelectedOption("just_me")}
+ className={`${
+ selectedOption === "just_me"
+ ? "text-sky-400 border-sky-400/70"
+ : "text-theme-text-primary border-theme-sidebar-border"
+ } min-w-[230px] h-11 p-4 rounded-[10px] border-2 justify-center items-center gap-[100px] inline-flex hover:border-sky-400/70 hover:text-sky-400 transition-all duration-300`}
+ >
+
+ {t("onboarding.userSetup.justMe")}
+
+
+
setSelectedOption("my_team")}
+ className={`${
+ selectedOption === "my_team"
+ ? "text-sky-400 border-sky-400/70"
+ : "text-theme-text-primary border-theme-sidebar-border"
+ } min-w-[230px] h-11 p-4 rounded-[10px] border-2 justify-center items-center gap-[100px] inline-flex hover:border-sky-400/70 hover:text-sky-400 transition-all duration-300`}
+ >
+
+ {t("onboarding.userSetup.myTeam")}
+
+
+
+
+ {selectedOption === "just_me" && (
+
+ )}
+ {selectedOption === "my_team" && (
+
+ )}
+
+ );
+}
+
+const JustMe = ({
+ setSingleUserPasswordValid,
+ enablePassword,
+ setEnablePassword,
+ justMeSubmitRef,
+ navigate,
+}) => {
+ const { t } = useTranslation();
+ const [itemSelected, setItemSelected] = useState(false);
+ const [password, setPassword] = useState("");
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const formData = new FormData(form);
+ const { error } = await System.updateSystemPassword({
+ usePassword: true,
+ newPassword: formData.get("password"),
+ });
+
+ if (error) {
+ showToast(`Failed to set password: ${error}`, "error");
+ return;
+ }
+
+ // Auto-request token with password that was just set so they
+ // are not redirected to login after completion.
+ const { token } = await System.requestToken({
+ password: formData.get("password"),
+ });
+ window.localStorage.removeItem(AUTH_USER);
+ window.localStorage.removeItem(AUTH_TIMESTAMP);
+ window.localStorage.setItem(AUTH_TOKEN, token);
+
+ navigate(paths.onboarding.dataHandling());
+ };
+
+ const setNewPassword = (e) => setPassword(e.target.value);
+ const handlePasswordChange = debounce(setNewPassword, 500);
+
+ function handleYes() {
+ setItemSelected(true);
+ setEnablePassword(true);
+ }
+
+ function handleNo() {
+ setItemSelected(true);
+ setEnablePassword(false);
+ }
+
+ useEffect(() => {
+ if (enablePassword && itemSelected && password.length >= 8) {
+ setSingleUserPasswordValid(true);
+ } else if (!enablePassword && itemSelected) {
+ setSingleUserPasswordValid(true);
+ } else {
+ setSingleUserPasswordValid(false);
+ }
+ });
+ return (
+
+
+
+ {t("onboarding.userSetup.setPassword")}
+
+
+
+
+ {t("common.yes")}
+
+
+
+
+ {t("common.no")}
+
+
+
+ {enablePassword && (
+
+
+ {t("onboarding.userSetup.instancePassword")}
+
+
+
+ {t("onboarding.userSetup.passwordReq")}
+
+ {t("onboarding.userSetup.passwordWarn")} {" "}
+
+
+
+ )}
+
+
+ );
+};
+
+const MyTeam = ({ setMultiUserLoginValid, myTeamSubmitRef, navigate }) => {
+ const { t } = useTranslation();
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const formData = new FormData(form);
+ const data = {
+ username: formData.get("username"),
+ password: formData.get("password"),
+ };
+ const { success, error } = await System.setupMultiUser(data);
+ if (!success) {
+ showToast(`Error: ${error}`, "error");
+ return;
+ }
+
+ navigate(paths.onboarding.dataHandling());
+ // Auto-request token with credentials that was just set so they
+ // are not redirected to login after completion.
+ const { user, token } = await System.requestToken(data);
+ window.localStorage.setItem(AUTH_USER, JSON.stringify(user));
+ window.localStorage.setItem(AUTH_TOKEN, token);
+ window.localStorage.removeItem(AUTH_TIMESTAMP);
+ };
+
+ const setNewUsername = (e) => setUsername(e.target.value);
+ const setNewPassword = (e) => setPassword(e.target.value);
+ const handleUsernameChange = debounce(setNewUsername, 500);
+ const handlePasswordChange = debounce(setNewPassword, 500);
+
+ useEffect(() => {
+ if (username.length >= 6 && password.length >= 8) {
+ setMultiUserLoginValid(true);
+ } else {
+ setMultiUserLoginValid(false);
+ }
+ }, [username, password]);
+ return (
+
+
+
+
+
+
+
+ {t("onboarding.userSetup.adminUsername")}
+
+
+
+
+ {t("onboarding.userSetup.adminUsernameReq")}
+
+
+
+ {t("onboarding.userSetup.adminPassword")}
+
+
+
+
+ {t("onboarding.userSetup.adminPasswordReq")}
+
+
+
+
+
+
+ {t("onboarding.userSetup.teamHint")}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/OnboardingFlow/Steps/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..e0e8fb2046010e1f5ca43c7b90bd591208b05261
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/Steps/index.jsx
@@ -0,0 +1,142 @@
+import { ArrowLeft, ArrowRight } from "@phosphor-icons/react";
+import { useState } from "react";
+import { isMobile } from "react-device-detect";
+import Home from "./Home";
+import LLMPreference from "./LLMPreference";
+import UserSetup from "./UserSetup";
+import DataHandling from "./DataHandling";
+import Survey from "./Survey";
+import CreateWorkspace from "./CreateWorkspace";
+
+const OnboardingSteps = {
+ home: Home,
+ "llm-preference": LLMPreference,
+ "user-setup": UserSetup,
+ "data-handling": DataHandling,
+ survey: Survey,
+ "create-workspace": CreateWorkspace,
+};
+
+export default OnboardingSteps;
+
+export function OnboardingLayout({ children }) {
+ const [header, setHeader] = useState({
+ title: "",
+ description: "",
+ });
+ const [backBtn, setBackBtn] = useState({
+ showing: false,
+ disabled: true,
+ onClick: () => null,
+ });
+ const [forwardBtn, setForwardBtn] = useState({
+ showing: false,
+ disabled: true,
+ onClick: () => null,
+ });
+
+ if (isMobile) {
+ return (
+
+
+
+
+
+ {header.title}
+
+
+ {header.description}
+
+
+ {children(setHeader, setBackBtn, setForwardBtn)}
+
+
+
+ {backBtn.showing && (
+
+
+
+ )}
+
+
+
+ {forwardBtn.showing && (
+
+
+
+ )}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {backBtn.showing && (
+
+
+
+ )}
+
+
+
+
+
+ {header.title}
+
+
+ {header.description}
+
+
+ {children(setHeader, setBackBtn, setForwardBtn)}
+
+
+
+ {forwardBtn.showing && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/OnboardingFlow/index.jsx b/frontend/src/pages/OnboardingFlow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c46b3c0bc908cafa90b3a884aa9d943d2098e7dd
--- /dev/null
+++ b/frontend/src/pages/OnboardingFlow/index.jsx
@@ -0,0 +1,21 @@
+import React from "react";
+import OnboardingSteps, { OnboardingLayout } from "./Steps";
+import { useParams } from "react-router-dom";
+
+export default function OnboardingFlow() {
+ const { step } = useParams();
+ const StepPage = OnboardingSteps[step || "home"];
+ if (step === "home" || !step) return ;
+
+ return (
+
+ {(setHeader, setBackBtn, setForwardBtn) => (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceChat/index.jsx b/frontend/src/pages/WorkspaceChat/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..aa924fc56568b16a7eb748e7507eff7b859ecaf2
--- /dev/null
+++ b/frontend/src/pages/WorkspaceChat/index.jsx
@@ -0,0 +1,54 @@
+import React, { useEffect, useState } from "react";
+import { default as WorkspaceChatContainer } from "@/components/WorkspaceChat";
+import Sidebar from "@/components/Sidebar";
+import { useParams } from "react-router-dom";
+import Workspace from "@/models/workspace";
+import PasswordModal, { usePasswordModal } from "@/components/Modals/Password";
+import { isMobile } from "react-device-detect";
+import { FullScreenLoader } from "@/components/Preloader";
+
+export default function WorkspaceChat() {
+ const { loading, requiresAuth, mode } = usePasswordModal();
+
+ if (loading) return ;
+ if (requiresAuth !== false) {
+ return <>{requiresAuth !== null && }>;
+ }
+
+ return ;
+}
+
+function ShowWorkspaceChat() {
+ const { slug } = useParams();
+ const [workspace, setWorkspace] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function getWorkspace() {
+ if (!slug) return;
+ const _workspace = await Workspace.bySlug(slug);
+ if (!_workspace) {
+ setLoading(false);
+ return;
+ }
+ const suggestedMessages = await Workspace.getSuggestedMessages(slug);
+ const pfpUrl = await Workspace.fetchPfp(slug);
+ setWorkspace({
+ ..._workspace,
+ suggestedMessages,
+ pfpUrl,
+ });
+ setLoading(false);
+ }
+ getWorkspace();
+ }, []);
+
+ return (
+ <>
+
+ {!isMobile && }
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/AgentLLMItem/index.jsx b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/AgentLLMItem/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3dc8953c4f7bb702fb981da3fc297a94d75171af
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/AgentLLMItem/index.jsx
@@ -0,0 +1,188 @@
+// This component differs from the main LLMItem in that it shows if a provider is
+// "ready for use" and if not - will then highjack the click handler to show a modal
+// of the provider options that must be saved to continue.
+import { createPortal } from "react-dom";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import { X, Gear } from "@phosphor-icons/react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { useEffect, useState } from "react";
+
+const NO_SETTINGS_NEEDED = ["default", "none"];
+export default function AgentLLMItem({
+ llm,
+ availableLLMs,
+ settings,
+ checked,
+ onClick,
+}) {
+ const { isOpen, openModal, closeModal } = useModal();
+ const { name, value, logo, description } = llm;
+ const [currentSettings, setCurrentSettings] = useState(settings);
+
+ useEffect(() => {
+ async function getSettings() {
+ if (isOpen) {
+ const _settings = await System.keys();
+ setCurrentSettings(_settings ?? {});
+ }
+ }
+ getSettings();
+ }, [isOpen]);
+
+ function handleProviderSelection() {
+ // Determine if provider needs additional setup because its minimum required keys are
+ // not yet set in settings.
+ if (!checked) {
+ const requiresAdditionalSetup = (llm.requiredConfig || []).some(
+ (key) => !currentSettings[key]
+ );
+ if (requiresAdditionalSetup) {
+ openModal();
+ return;
+ }
+ onClick(value);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
{name}
+
{description}
+
+
+ {checked &&
+ value !== "none" &&
+ !NO_SETTINGS_NEEDED.includes(value) && (
+
{
+ e.preventDefault();
+ openModal();
+ }}
+ className="border-none p-2 text-white/60 hover:text-white hover:bg-theme-bg-hover rounded-md transition-all duration-300"
+ title="Edit Settings"
+ >
+
+
+ )}
+
+
+
+ >
+ );
+}
+
+function SetupProvider({
+ availableLLMs,
+ isOpen,
+ provider,
+ closeModal,
+ postSubmit,
+ settings,
+}) {
+ if (!isOpen) return null;
+ const LLMOption = availableLLMs.find((llm) => llm.value === provider);
+ if (!LLMOption) return null;
+
+ async function handleUpdate(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ const data = {};
+ const form = new FormData(e.target);
+ for (var [key, value] of form.entries()) data[key] = value;
+ const { error } = await System.updateSystem(data);
+ if (error) {
+ showToast(`Failed to save ${LLMOption.name} settings: ${error}`, "error");
+ return;
+ }
+
+ closeModal();
+ postSubmit();
+ return false;
+ }
+
+ // Cannot do nested forms, it will cause all sorts of issues, so we portal this out
+ // to the parent container form so we don't have nested forms.
+ return createPortal(
+
+
+
+
+
+
+ {LLMOption.name} Settings
+
+
+
+
+
+
+
+
+
+
+ To use {LLMOption.name} as this workspace's agent LLM you need
+ to set it up first.
+
+
+ {LLMOption.options(settings, { credentialsOnly: true })}
+
+
+
+
+
+ Cancel
+
+
+ Save {LLMOption.name} settings
+
+
+
+
+
+ ,
+ document.getElementById("workspace-agent-settings-container")
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/index.jsx b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9710243dacbb257100e62f50fb770f28358a83d4
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/index.jsx
@@ -0,0 +1,210 @@
+import React, { useEffect, useRef, useState } from "react";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import AgentLLMItem from "./AgentLLMItem";
+import { AVAILABLE_LLM_PROVIDERS } from "@/pages/GeneralSettings/LLMPreference";
+import { CaretUpDown, Gauge, MagnifyingGlass, X } from "@phosphor-icons/react";
+import AgentModelSelection from "../AgentModelSelection";
+import { useTranslation } from "react-i18next";
+
+const ENABLED_PROVIDERS = [
+ "openai",
+ "anthropic",
+ "lmstudio",
+ "ollama",
+ "localai",
+ "groq",
+ "azure",
+ "koboldcpp",
+ "togetherai",
+ "openrouter",
+ "novita",
+ "mistral",
+ "perplexity",
+ "textgenwebui",
+ "generic-openai",
+ "bedrock",
+ "fireworksai",
+ "deepseek",
+ "ppio",
+ "litellm",
+ "apipie",
+ "xai",
+ "nvidia-nim",
+ "gemini",
+ "moonshotai",
+ "cometapi",
+ // TODO: More agent support.
+ // "cohere", // Has tool calling and will need to build explicit support
+ // "huggingface" // Can be done but already has issues with no-chat templated. Needs to be tested.
+];
+const WARN_PERFORMANCE = [
+ "lmstudio",
+ "koboldcpp",
+ "ollama",
+ "localai",
+ "textgenwebui",
+];
+
+const LLM_DEFAULT = {
+ name: "System Default",
+ value: "none",
+ logo: AnythingLLMIcon,
+ options: () => ,
+ description:
+ "Agents will use the workspace or system LLM unless otherwise specified.",
+ requiredConfig: [],
+};
+
+const LLMS = [
+ LLM_DEFAULT,
+ ...AVAILABLE_LLM_PROVIDERS.filter((llm) =>
+ ENABLED_PROVIDERS.includes(llm.value)
+ ),
+];
+
+export default function AgentLLMSelection({
+ settings,
+ workspace,
+ setHasChanges,
+}) {
+ const [filteredLLMs, setFilteredLLMs] = useState([]);
+ const [selectedLLM, setSelectedLLM] = useState(
+ workspace?.agentProvider ?? "none"
+ );
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+ const { t } = useTranslation();
+ function updateLLMChoice(selection) {
+ setSearchQuery("");
+ setSelectedLLM(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ }
+
+ function handleXButton() {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ }
+
+ useEffect(() => {
+ const filtered = LLMS.filter((llm) =>
+ llm.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredLLMs(filtered);
+ }, [searchQuery, selectedLLM]);
+
+ const selectedLLMObject = LLMS.find((llm) => llm.value === selectedLLM);
+ return (
+
+ {WARN_PERFORMANCE.includes(selectedLLM) && (
+
+
+
+
{t("agent.performance-warning")}
+
+
+ )}
+
+
+
+ {t("agent.provider.title")}
+
+
+ {t("agent.provider.description")}
+
+
+
+
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredLLMs.map((llm) => {
+ return (
+
updateLLMChoice(llm.value)}
+ />
+ );
+ })}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedLLMObject.name}
+
+
+ {selectedLLMObject.description}
+
+
+
+
+
+ )}
+
+ {selectedLLM !== "none" && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentModelSelection/index.jsx b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentModelSelection/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c84014ef19dc029f1326e52912d6329fe65f8679
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentModelSelection/index.jsx
@@ -0,0 +1,170 @@
+import useGetProviderModels, {
+ DISABLED_PROVIDERS,
+} from "@/hooks/useGetProvidersModels";
+import paths from "@/utils/paths";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router-dom";
+
+/**
+ * These models do NOT support function calling
+ * or do not support system prompts
+ * and therefore are not supported for agents.
+ * @param {string} provider - The AI provider.
+ * @param {string} model - The model name.
+ * @returns {boolean} Whether the model is supported for agents.
+ */
+function supportedModel(provider, model = "") {
+ if (provider === "openai") {
+ return (
+ [
+ "gpt-3.5-turbo-0301",
+ "gpt-4-turbo-2024-04-09",
+ "gpt-4-turbo",
+ "o1-preview",
+ "o1-preview-2024-09-12",
+ "o1-mini",
+ "o1-mini-2024-09-12",
+ "o3-mini",
+ "o3-mini-2025-01-31",
+ ].includes(model) === false
+ );
+ }
+
+ return true;
+}
+
+export default function AgentModelSelection({
+ provider,
+ workspace,
+ setHasChanges,
+}) {
+ const { slug } = useParams();
+ const { defaultModels, customModels, loading } =
+ useGetProviderModels(provider);
+
+ const { t } = useTranslation();
+ if (DISABLED_PROVIDERS.includes(provider)) {
+ return (
+
+
+ Multi-model support is not supported for this provider yet.
+
+ Agent's will use{" "}
+
+ the model set for the workspace
+ {" "}
+ or{" "}
+
+ the model set for the system.
+
+
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+
+
+ {t("agent.mode.chat.title")}
+
+
+ {t("agent.mode.chat.description")}
+
+
+
+
+ {t("agent.mode.wait")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("agent.mode.title")}
+
+
+ {t("agent.mode.description")}
+
+
+
+
{
+ setHasChanges(true);
+ }}
+ className="border-none bg-theme-settings-input-bg text-white text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ >
+ {defaultModels.length > 0 && (
+
+ {defaultModels.map((model) => {
+ if (!supportedModel(provider, model)) return null;
+ return (
+
+ {model}
+
+ );
+ })}
+
+ )}
+ {Array.isArray(customModels) && customModels.length > 0 && (
+
+ {customModels.map((model) => {
+ if (!supportedModel(provider, model.id)) return null;
+
+ return (
+
+ {model.id}
+
+ );
+ })}
+
+ )}
+ {/* For providers like TogetherAi where we partition model by creator entity. */}
+ {!Array.isArray(customModels) &&
+ Object.keys(customModels).length > 0 && (
+ <>
+ {Object.entries(customModels).map(([organization, models]) => (
+
+ {models.map((model) => {
+ if (!supportedModel(provider, model.id)) return null;
+ return (
+
+ {model.name}
+
+ );
+ })}
+
+ ))}
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/AgentConfig/index.jsx b/frontend/src/pages/WorkspaceSettings/AgentConfig/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..1508f0ad2c4dfa69a49d50b25469879c8d885353
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/AgentConfig/index.jsx
@@ -0,0 +1,149 @@
+import System from "@/models/system";
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+import { castToType } from "@/utils/types";
+import { useEffect, useRef, useState } from "react";
+import AgentLLMSelection from "./AgentLLMSelection";
+import Admin from "@/models/admin";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import paths from "@/utils/paths";
+import useUser from "@/hooks/useUser";
+
+export default function WorkspaceAgentConfiguration({ workspace }) {
+ const { user } = useUser();
+ const [settings, setSettings] = useState({});
+ const [hasChanges, setHasChanges] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const formEl = useRef(null);
+
+ useEffect(() => {
+ async function fetchSettings() {
+ const _settings = await System.keys();
+ const _preferences = await Admin.systemPreferences();
+ setSettings({ ..._settings, preferences: _preferences.settings } ?? {});
+ setLoading(false);
+ }
+ fetchSettings();
+ }, []);
+
+ const handleUpdate = async (e) => {
+ setSaving(true);
+ e.preventDefault();
+ const data = {
+ workspace: {},
+ system: {},
+ env: {},
+ };
+
+ const form = new FormData(formEl.current);
+ for (var [key, value] of form.entries()) {
+ if (key.startsWith("system::")) {
+ const [_, label] = key.split("system::");
+ data.system[label] = String(value);
+ continue;
+ }
+
+ if (key.startsWith("env::")) {
+ const [_, label] = key.split("env::");
+ data.env[label] = String(value);
+ continue;
+ }
+
+ data.workspace[key] = castToType(key, value);
+ }
+
+ const { workspace: updatedWorkspace, message } = await Workspace.update(
+ workspace.slug,
+ data.workspace
+ );
+ await Admin.updateSystemPreferences(data.system);
+ await System.updateSystem(data.env);
+
+ if (!!updatedWorkspace) {
+ showToast("Workspace updated!", "success", { clear: true });
+ } else {
+ showToast(`Error: ${message}`, "error", { clear: true });
+ }
+
+ setSaving(false);
+ setHasChanges(false);
+ };
+
+ if (!workspace || loading) return
;
+ return (
+
+
setHasChanges(true)}
+ id="agent-settings-form"
+ className="w-1/2 flex flex-col gap-y-6"
+ >
+
+ {(!user || user?.role === "admin") && (
+ <>
+ {!hasChanges && (
+
+
+ Configure Agent Skills
+
+
+ Customize and enhance the default agent's capabilities by
+ enabling or disabling specific skills. These settings will be
+ applied across all workspaces.
+
+
+ )}
+ >
+ )}
+
+ {hasChanges && (
+
+ {saving ? "Updating agent..." : "Update workspace agent"}
+
+ )}
+
+
+ );
+}
+
+function LoadingSkeleton() {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatHistorySettings/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatHistorySettings/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..115cfc3158b4474916c8c40135416bee86a431e0
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatHistorySettings/index.jsx
@@ -0,0 +1,32 @@
+import { useTranslation } from "react-i18next";
+export default function ChatHistorySettings({ workspace, setHasChanges }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("chat.history.title")}
+
+
+ {t("chat.history.desc-start")}
+ {t("chat.history.recommend")}
+ {t("chat.history.desc-end")}
+
+
+
e.target.blur()}
+ defaultValue={workspace?.openAiHistory ?? 20}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ placeholder="20"
+ required={true}
+ autoComplete="off"
+ onChange={() => setHasChanges(true)}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatModeSelection/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatModeSelection/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3b507c721c824cb7be03bd4b68b4dcb9f7d7d435
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatModeSelection/index.jsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+export default function ChatModeSelection({ workspace, setHasChanges }) {
+ const [chatMode, setChatMode] = useState(workspace?.chatMode || "chat");
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("chat.mode.title")}
+
+
+
+
+
+
+ {
+ setChatMode("chat");
+ setHasChanges(true);
+ }}
+ className="transition-bg duration-200 px-6 py-1 text-md text-white/60 disabled:text-white bg-transparent disabled:bg-[#687280] rounded-md"
+ >
+ {t("chat.mode.chat.title")}
+
+ {
+ setChatMode("query");
+ setHasChanges(true);
+ }}
+ className="transition-bg duration-200 px-6 py-1 text-md text-white/60 disabled:text-white bg-transparent disabled:bg-[#687280] rounded-md"
+ >
+ {t("chat.mode.query.title")}
+
+
+
+ {chatMode === "chat" ? (
+ <>
+ {t("chat.mode.chat.title")} {" "}
+ {t("chat.mode.chat.desc-start")}{" "}
+ {t("chat.mode.chat.and")} {" "}
+ {t("chat.mode.chat.desc-end")}
+ >
+ ) : (
+ <>
+ {t("chat.mode.query.title")} {" "}
+ {t("chat.mode.query.desc-start")}{" "}
+ {t("chat.mode.query.only")} {" "}
+ {t("chat.mode.query.desc-end")}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/ChatPromptHistory/PromptHistoryItem/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/ChatPromptHistory/PromptHistoryItem/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..278574a798f5d64eb71863261e0b9d100bd62c0e
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/ChatPromptHistory/PromptHistoryItem/index.jsx
@@ -0,0 +1,133 @@
+import { DotsThreeVertical } from "@phosphor-icons/react";
+import { useRef, useState, useEffect } from "react";
+import PromptHistory from "@/models/promptHistory";
+import { useTranslation } from "react-i18next";
+import moment from "moment";
+import truncate from "truncate";
+
+const MAX_PROMPT_LENGTH = 200; // chars
+
+export default function PromptHistoryItem({
+ id,
+ prompt,
+ modifiedAt,
+ user,
+ onRestore,
+ setHistory,
+ onPublishClick,
+}) {
+ const { t } = useTranslation();
+ const [showMenu, setShowMenu] = useState(false);
+ const menuRef = useRef(null);
+ const menuButtonRef = useRef(null);
+ const [expanded, setExpanded] = useState(false);
+
+ const deleteHistory = async (id) => {
+ if (window.confirm(t("chat.prompt.history.deleteConfirm"))) {
+ const { success } = await PromptHistory.delete(id);
+ if (success) {
+ setHistory((prevHistory) =>
+ prevHistory.filter((item) => item.id !== id)
+ );
+ }
+ }
+ };
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (
+ showMenu &&
+ !menuRef.current.contains(event.target) &&
+ !menuButtonRef.current.contains(event.target)
+ ) {
+ setShowMenu(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [showMenu]);
+
+ return (
+
+
+
+ {user && (
+ <>
+ {user.username} {" "}
+ •
+ >
+ )}
+
+ {moment(modifiedAt).fromNow()}
+
+
+
+
+ {t("chat.prompt.history.restore")}
+
+
+
setShowMenu(!showMenu)}
+ >
+
+
+ {showMenu && (
+
+ {
+ setShowMenu(false);
+ onPublishClick(prompt);
+ }}
+ >
+ {t("chat.prompt.history.publish")}
+
+ {
+ setShowMenu(false);
+ deleteHistory(id);
+ }}
+ >
+ {t("chat.prompt.history.delete")}
+
+
+ )}
+
+
+
+
+
+ {prompt.length > MAX_PROMPT_LENGTH && !expanded ? (
+ <>
+ {truncate(prompt, MAX_PROMPT_LENGTH)}{" "}
+ setExpanded(!expanded)}
+ >
+ {t("chat.prompt.history.expand")}
+
+ >
+ ) : (
+ prompt
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/ChatPromptHistory/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/ChatPromptHistory/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b395caf44dd0eb5cf1d01044fccd5a22558dd67e
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/ChatPromptHistory/index.jsx
@@ -0,0 +1,117 @@
+import { useEffect, useState, forwardRef } from "react";
+import { useTranslation } from "react-i18next";
+import { X } from "@phosphor-icons/react";
+import PromptHistory from "@/models/promptHistory";
+import PromptHistoryItem from "./PromptHistoryItem";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+
+export default forwardRef(function ChatPromptHistory(
+ { show, workspaceSlug, onRestore, onClose, onPublishClick },
+ ref
+) {
+ const { t } = useTranslation();
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ function loadHistory() {
+ if (!workspaceSlug) return;
+ setLoading(true);
+ PromptHistory.forWorkspace(workspaceSlug)
+ .then((historyData) => {
+ setHistory(historyData);
+ })
+ .catch((error) => {
+ console.error(error);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+
+ function handleClearAll() {
+ if (!workspaceSlug) return;
+ if (window.confirm(t("chat.prompt.history.clearAllConfirm"))) {
+ PromptHistory.clearAll(workspaceSlug)
+ .then(({ success }) => {
+ if (success) setHistory([]);
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+ }
+ }
+
+ useEffect(() => {
+ if (show && workspaceSlug) loadHistory();
+ }, [show, workspaceSlug]);
+
+ return (
+
+
+
+ {t("chat.prompt.history.title")}
+
+
+ {history.length > 0 && (
+
+ {t("chat.prompt.history.clearAll")}
+
+ )}
+
+
+
+
+
+
+ {loading ? (
+
+ ) : history.length === 0 ? (
+
+ {t("chat.prompt.history.noHistory")}
+
+ ) : (
+ history.map((item) => (
+
onRestore(item.prompt)}
+ onPublishClick={onPublishClick}
+ setHistory={setHistory}
+ />
+ ))
+ )}
+
+
+ );
+});
+
+function LoaderSkeleton() {
+ const highlightColor = "var(--theme-bg-primary)";
+ const baseColor = "var(--theme-bg-secondary)";
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a27583a58ee881c27e730395021d05d6a2364118
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/index.jsx
@@ -0,0 +1,248 @@
+import { useEffect, useState, useRef, Fragment } from "react";
+import { chatPrompt } from "@/utils/chat";
+import { useTranslation } from "react-i18next";
+import SystemPromptVariable from "@/models/systemPromptVariable";
+import Highlighter from "react-highlight-words";
+import { Link, useSearchParams } from "react-router-dom";
+import paths from "@/utils/paths";
+import ChatPromptHistory from "./ChatPromptHistory";
+import PublishEntityModal from "@/components/CommunityHub/PublishEntityModal";
+import { useModal } from "@/hooks/useModal";
+
+// TODO: Move to backend and have user-language sensitive default prompt
+const DEFAULT_PROMPT =
+ "Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed.";
+
+export default function ChatPromptSettings({ workspace, setHasChanges }) {
+ const { t } = useTranslation();
+ const [availableVariables, setAvailableVariables] = useState([]);
+ const [prompt, setPrompt] = useState(chatPrompt(workspace));
+ const [isEditing, setIsEditing] = useState(false);
+ const [showPromptHistory, setShowPromptHistory] = useState(false);
+ const promptRef = useRef(null);
+ const promptHistoryRef = useRef(null);
+ const historyButtonRef = useRef(null);
+ const [searchParams] = useSearchParams();
+ const {
+ isOpen: showPublishModal,
+ closeModal: closePublishModal,
+ openModal: openPublishModal,
+ } = useModal();
+ const [currentPrompt, setCurrentPrompt] = useState(chatPrompt(workspace));
+
+ useEffect(() => {
+ async function setupVariableHighlighting() {
+ const { variables } = await SystemPromptVariable.getAll();
+ setAvailableVariables(variables);
+ }
+ setupVariableHighlighting();
+ }, []);
+
+ useEffect(() => {
+ if (searchParams.get("action") === "focus-system-prompt")
+ setIsEditing(true);
+ }, [searchParams]);
+
+ useEffect(() => {
+ if (isEditing && promptRef.current) {
+ promptRef.current.focus();
+ }
+ }, [isEditing]);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (
+ promptHistoryRef.current &&
+ !promptHistoryRef.current.contains(event.target) &&
+ historyButtonRef.current &&
+ !historyButtonRef.current.contains(event.target)
+ ) {
+ setShowPromptHistory(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const handleRestore = (prompt) => {
+ setPrompt(prompt);
+ setShowPromptHistory(false);
+ setHasChanges(true);
+ };
+
+ const handlePublishClick = (prompt) => {
+ setCurrentPrompt(prompt);
+ setShowPromptHistory(false);
+ openPublishModal();
+ };
+
+ return (
+ <>
+
{
+ setShowPromptHistory(false);
+ }}
+ />
+
+
+
+
+ {t("chat.prompt.title")}
+
+
+
+ {t("chat.prompt.description")}
+
+
+ You can insert{" "}
+
+ prompt variables
+ {" "}
+ like:{" "}
+ {availableVariables.slice(0, 3).map((v, i) => (
+
+
+ {`{${v.key}}`}
+
+ {i < availableVariables.length - 1 && ", "}
+
+ ))}
+ {availableVariables.length > 3 && (
+
+ +{availableVariables.length - 3} more...
+
+ )}
+
+
+
+
+
+
{
+ e.preventDefault();
+ setShowPromptHistory(!showPromptHistory);
+ }}
+ >
+ {showPromptHistory ? "Hide History" : "View History"}
+
+
+
+ {DEFAULT_PROMPT}
+
+ {isEditing ? (
+
{
+ const length = e.target.value.length;
+ e.target.setSelectionRange(length, length);
+ }}
+ onBlur={(e) => {
+ setIsEditing(false);
+ setPrompt(e.target.value);
+ }}
+ onChange={(e) => {
+ setPrompt(e.target.value);
+ setHasChanges(true);
+ }}
+ onPaste={(e) => {
+ setPrompt(e.target.value);
+ setHasChanges(true);
+ }}
+ style={{
+ resize: "vertical",
+ overflowY: "scroll",
+ minHeight: "150px",
+ }}
+ defaultValue={prompt}
+ className="border-none bg-theme-settings-input-bg text-white text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5 mt-2"
+ />
+ ) : (
+ setIsEditing(true)}
+ style={{
+ resize: "vertical",
+ overflowY: "scroll",
+ minHeight: "150px",
+ }}
+ className="border-none bg-theme-settings-input-bg text-white text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5 mt-2"
+ >
+ `{${v.key}}`)}
+ autoEscape={true}
+ caseSensitive={true}
+ textToHighlight={prompt}
+ />
+
+ )}
+
+
+ {prompt !== DEFAULT_PROMPT && (
+ <>
+
handleRestore(DEFAULT_PROMPT)}
+ className="text-theme-text-primary hover:text-white light:hover:text-black text-xs font-medium"
+ >
+ Clear
+
+
{
+ setCurrentPrompt(prompt);
+ openPublishModal();
+ }}
+ />
+ >
+ )}
+
+
+
+
+ >
+ );
+}
+
+function PublishPromptCTA({ hidden = false, onClick }) {
+ if (hidden) return null;
+ return (
+
+ Publish to Community Hub
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatQueryRefusalResponse/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatQueryRefusalResponse/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..90e908bf277a16997668783c628c845bc577457a
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatQueryRefusalResponse/index.jsx
@@ -0,0 +1,32 @@
+import { chatQueryRefusalResponse } from "@/utils/chat";
+import { useTranslation } from "react-i18next";
+export default function ChatQueryRefusalResponse({ workspace, setHasChanges }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("chat.refusal.title")}
+
+
+ {t("chat.refusal.desc-start")}{" "}
+
+ {t("chat.refusal.query")}
+ {" "}
+ {t("chat.refusal.desc-end")}
+
+
+
setHasChanges(true)}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatTemperatureSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatTemperatureSettings/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ccdab89c15a5ce7d025695c5d4156dce5cbf3d1b
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/ChatTemperatureSettings/index.jsx
@@ -0,0 +1,48 @@
+import { useTranslation } from "react-i18next";
+function recommendedSettings(provider = null) {
+ switch (provider) {
+ case "mistral":
+ return { temp: 0 };
+ default:
+ return { temp: 0.7 };
+ }
+}
+
+export default function ChatTemperatureSettings({
+ settings,
+ workspace,
+ setHasChanges,
+}) {
+ const defaults = recommendedSettings(settings?.LLMProvider);
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("chat.temperature.title")}
+
+
+ {t("chat.temperature.desc-start")}
+
+ {t("chat.temperature.desc-end")}
+
+
+ {t("chat.temperature.hint")}
+
+
+
e.target.blur()}
+ defaultValue={workspace?.openAiTemp ?? defaults.temp}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ placeholder="0.7"
+ required={true}
+ autoComplete="off"
+ onChange={() => setHasChanges(true)}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/ChatModelSelection/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/ChatModelSelection/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9440cddbc21b51dfc03ab2e84869f28609280a5b
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/ChatModelSelection/index.jsx
@@ -0,0 +1,112 @@
+import useGetProviderModels, {
+ DISABLED_PROVIDERS,
+} from "@/hooks/useGetProvidersModels";
+import { useTranslation } from "react-i18next";
+
+export default function ChatModelSelection({
+ provider,
+ workspace,
+ setHasChanges,
+}) {
+ const { defaultModels, customModels, loading } =
+ useGetProviderModels(provider);
+ const { t } = useTranslation();
+ if (DISABLED_PROVIDERS.includes(provider)) return null;
+
+ if (loading) {
+ return (
+
+
+
+ {t("chat.model.title")}
+
+
+ {t("chat.model.description")}
+
+
+
+
+ -- waiting for models --
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("chat.model.title")}
+
+
+ {t("chat.model.description")}
+
+
+
+
{
+ setHasChanges(true);
+ }}
+ className="border-none bg-theme-settings-input-bg text-white text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ >
+ {defaultModels.length > 0 && (
+
+ {defaultModels.map((model) => {
+ return (
+
+ {model}
+
+ );
+ })}
+
+ )}
+ {Array.isArray(customModels) && customModels.length > 0 && (
+
+ {customModels.map((model) => {
+ return (
+
+ {model.id}
+
+ );
+ })}
+
+ )}
+ {/* For providers like TogetherAi where we partition model by creator entity. */}
+ {!Array.isArray(customModels) &&
+ Object.keys(customModels).length > 0 && (
+ <>
+ {Object.entries(customModels).map(([organization, models]) => (
+
+ {models.map((model) => (
+
+ {model.name}
+
+ ))}
+
+ ))}
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/WorkspaceLLMItem/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/WorkspaceLLMItem/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c5e06fce4a85ecbbaafdf2d6f67e6b3a1e89ca7
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/WorkspaceLLMItem/index.jsx
@@ -0,0 +1,186 @@
+// This component differs from the main LLMItem in that it shows if a provider is
+// "ready for use" and if not - will then highjack the click handler to show a modal
+// of the provider options that must be saved to continue.
+import { createPortal } from "react-dom";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import { X, Gear } from "@phosphor-icons/react";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import { useEffect, useState } from "react";
+
+const NO_SETTINGS_NEEDED = ["default"];
+export default function WorkspaceLLM({
+ llm,
+ availableLLMs,
+ settings,
+ checked,
+ onClick,
+}) {
+ const { isOpen, openModal, closeModal } = useModal();
+ const { name, value, logo, description } = llm;
+ const [currentSettings, setCurrentSettings] = useState(settings);
+
+ useEffect(() => {
+ async function getSettings() {
+ if (isOpen) {
+ const _settings = await System.keys();
+ setCurrentSettings(_settings ?? {});
+ }
+ }
+ getSettings();
+ }, [isOpen]);
+
+ function handleProviderSelection() {
+ // Determine if provider needs additional setup because its minimum required keys are
+ // not yet set in settings.
+ if (!checked) {
+ const requiresAdditionalSetup = (llm.requiredConfig || []).some(
+ (key) => !currentSettings[key]
+ );
+ if (requiresAdditionalSetup) {
+ openModal();
+ return;
+ }
+ onClick(value);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
{name}
+
{description}
+
+
+ {checked && !NO_SETTINGS_NEEDED.includes(value) && (
+
{
+ e.preventDefault();
+ openModal();
+ }}
+ className="p-2 text-white/60 hover:text-white hover:bg-theme-bg-hover rounded-md transition-all duration-300"
+ title="Edit Settings"
+ >
+
+
+ )}
+
+
+
+ >
+ );
+}
+
+function SetupProvider({
+ availableLLMs,
+ isOpen,
+ provider,
+ closeModal,
+ postSubmit,
+ settings,
+}) {
+ if (!isOpen) return null;
+ const LLMOption = availableLLMs.find((llm) => llm.value === provider);
+ if (!LLMOption) return null;
+
+ async function handleUpdate(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ const data = {};
+ const form = new FormData(e.target);
+ for (var [key, value] of form.entries()) data[key] = value;
+ const { error } = await System.updateSystem(data);
+ if (error) {
+ showToast(`Failed to save ${LLMOption.name} settings: ${error}`, "error");
+ return;
+ }
+
+ closeModal();
+ postSubmit();
+ return false;
+ }
+
+ // Cannot do nested forms, it will cause all sorts of issues, so we portal this out
+ // to the parent container form so we don't have nested forms.
+ return createPortal(
+
+
+
+
+
+
+ {LLMOption.name} Settings
+
+
+
+
+
+
+
+
+
+
+ To use {LLMOption.name} as this workspace's LLM you need to
+ set it up first.
+
+
+ {LLMOption.options(settings, { credentialsOnly: true })}
+
+
+
+
+
+ Cancel
+
+
+ Save settings
+
+
+
+
+
+ ,
+ document.getElementById("workspace-chat-settings-container")
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0feb6f0e02b9caa31a9c31f0ef37718385203c23
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/WorkspaceLLMSelection/index.jsx
@@ -0,0 +1,223 @@
+import React, { useEffect, useRef, useState } from "react";
+import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png";
+import WorkspaceLLMItem from "./WorkspaceLLMItem";
+import { AVAILABLE_LLM_PROVIDERS } from "@/pages/GeneralSettings/LLMPreference";
+import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
+import ChatModelSelection from "./ChatModelSelection";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+
+// Some providers do not support model selection via /models.
+// In that case we allow the user to enter the model name manually and hope they
+// type it correctly.
+const FREE_FORM_LLM_SELECTION = ["bedrock", "azure", "generic-openai"];
+
+// Some providers do not support model selection via /models
+// and only have a fixed single-model they can use.
+const NO_MODEL_SELECTION = ["default", "huggingface"];
+
+// Some providers we just fully disable for ease of use.
+const DISABLED_PROVIDERS = [];
+
+const LLM_DEFAULT = {
+ name: "System default",
+ value: "default",
+ logo: AnythingLLMIcon,
+ options: () => ,
+ description: "Use the system LLM preference for this workspace.",
+ requiredConfig: [],
+};
+
+const LLMS = [LLM_DEFAULT, ...AVAILABLE_LLM_PROVIDERS].filter(
+ (llm) => !DISABLED_PROVIDERS.includes(llm.value)
+);
+
+export default function WorkspaceLLMSelection({
+ settings,
+ workspace,
+ setHasChanges,
+}) {
+ const [filteredLLMs, setFilteredLLMs] = useState([]);
+ const [selectedLLM, setSelectedLLM] = useState(
+ workspace?.chatProvider ?? "default"
+ );
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchMenuOpen, setSearchMenuOpen] = useState(false);
+ const searchInputRef = useRef(null);
+ const { t } = useTranslation();
+ function updateLLMChoice(selection) {
+ setSearchQuery("");
+ setSelectedLLM(selection);
+ setSearchMenuOpen(false);
+ setHasChanges(true);
+ }
+
+ function handleXButton() {
+ if (searchQuery.length > 0) {
+ setSearchQuery("");
+ if (searchInputRef.current) searchInputRef.current.value = "";
+ } else {
+ setSearchMenuOpen(!searchMenuOpen);
+ }
+ }
+
+ useEffect(() => {
+ const filtered = LLMS.filter((llm) =>
+ llm.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ setFilteredLLMs(filtered);
+ }, [LLMS, searchQuery, selectedLLM]);
+ const selectedLLMObject = LLMS.find((llm) => llm.value === selectedLLM);
+
+ return (
+
+
+
+ {t("chat.llm.title")}
+
+
+ {t("chat.llm.description")}
+
+
+
+
+
+ {searchMenuOpen && (
+
setSearchMenuOpen(false)}
+ />
+ )}
+ {searchMenuOpen ? (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ ref={searchInputRef}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.preventDefault();
+ }}
+ />
+
+
+
+ {filteredLLMs.map((llm) => {
+ return (
+ updateLLMChoice(llm.value)}
+ />
+ );
+ })}
+
+
+
+ ) : (
+
setSearchMenuOpen(true)}
+ >
+
+
+
+
+ {selectedLLMObject.name}
+
+
+ {selectedLLMObject.description}
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+// TODO: Add this to agent selector as well as make generic component.
+function ModelSelector({ selectedLLM, workspace, setHasChanges }) {
+ if (NO_MODEL_SELECTION.includes(selectedLLM)) {
+ if (selectedLLM !== "default") {
+ return (
+
+
+ Multi-model support is not supported for this provider yet.
+
+ This workspace will use{" "}
+
+ the model set for the system.
+
+
+
+ );
+ }
+ return null;
+ }
+
+ if (FREE_FORM_LLM_SELECTION.includes(selectedLLM)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function FreeFormLLMInput({ workspace, setHasChanges }) {
+ const { t } = useTranslation();
+ return (
+
+
{t("chat.model.title")}
+
+ {t("chat.model.description")}
+
+
setHasChanges(true)}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ placeholder="Enter model name exactly as referenced in the API (e.g., gpt-3.5-turbo)"
+ />
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/ChatSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/ChatSettings/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d551183e08a3aa29c9324c1951d967830a5145d0
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/ChatSettings/index.jsx
@@ -0,0 +1,92 @@
+import System from "@/models/system";
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+import { castToType } from "@/utils/types";
+import { useEffect, useRef, useState } from "react";
+import ChatHistorySettings from "./ChatHistorySettings";
+import ChatPromptSettings from "./ChatPromptSettings";
+import ChatTemperatureSettings from "./ChatTemperatureSettings";
+import ChatModeSelection from "./ChatModeSelection";
+import WorkspaceLLMSelection from "./WorkspaceLLMSelection";
+import ChatQueryRefusalResponse from "./ChatQueryRefusalResponse";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function ChatSettings({ workspace }) {
+ const [settings, setSettings] = useState({});
+ const [hasChanges, setHasChanges] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ const formEl = useRef(null);
+ useEffect(() => {
+ async function fetchSettings() {
+ const _settings = await System.keys();
+ setSettings(_settings ?? {});
+ }
+ fetchSettings();
+ }, []);
+
+ const handleUpdate = async (e) => {
+ setSaving(true);
+ e.preventDefault();
+ const data = {};
+ const form = new FormData(formEl.current);
+ for (var [key, value] of form.entries()) data[key] = castToType(key, value);
+ const { workspace: updatedWorkspace, message } = await Workspace.update(
+ workspace.slug,
+ data
+ );
+ if (!!updatedWorkspace) {
+ showToast("Workspace updated!", "success", { clear: true });
+ } else {
+ showToast(`Error: ${message}`, "error", { clear: true });
+ }
+ setSaving(false);
+ setHasChanges(false);
+ };
+
+ if (!workspace) return null;
+ return (
+
+
+ {hasChanges && (
+
+
+ {saving ? "Updating..." : "Update Workspace"}
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0023e33010a77ffa3e0d1b3d6a73a4078c798216
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+import { useParams } from "react-router-dom";
+import Workspace from "@/models/workspace";
+import paths from "@/utils/paths";
+import { useTranslation } from "react-i18next";
+import showToast from "@/utils/toast";
+
+export default function DeleteWorkspace({ workspace }) {
+ const { slug } = useParams();
+ const [deleting, setDeleting] = useState(false);
+ const { t } = useTranslation();
+
+ const deleteWorkspace = async () => {
+ if (
+ !window.confirm(
+ `${t("general.delete.confirm-start")} ${workspace.name} ${t(
+ "general.delete.confirm-end"
+ )}`
+ )
+ )
+ return false;
+
+ setDeleting(true);
+ const success = await Workspace.delete(workspace.slug);
+ if (!success) {
+ showToast("Workspace could not be deleted!", "error", { clear: true });
+ setDeleting(false);
+ return;
+ }
+
+ workspace.slug === slug
+ ? (window.location = paths.home())
+ : window.location.reload();
+ };
+ return (
+
+
{t("general.delete.title")}
+
+ {t("general.delete.description")}
+
+
+ {deleting ? t("general.delete.deleting") : t("general.delete.delete")}
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/SuggestedChatMessages/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/SuggestedChatMessages/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..df3d88259e87ff11bf8f46d9383d9458817d9272
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/SuggestedChatMessages/index.jsx
@@ -0,0 +1,195 @@
+import PreLoader from "@/components/Preloader";
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+import { useEffect, useState } from "react";
+import { Plus, X } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+
+export default function SuggestedChatMessages({ slug }) {
+ const [suggestedMessages, setSuggestedMessages] = useState([]);
+ const [editingIndex, setEditingIndex] = useState(-1);
+ const [newMessage, setNewMessage] = useState({ heading: "", message: "" });
+ const [hasChanges, setHasChanges] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const { t } = useTranslation();
+ useEffect(() => {
+ async function fetchWorkspace() {
+ if (!slug) return;
+ const suggestedMessages = await Workspace.getSuggestedMessages(slug);
+ setSuggestedMessages(suggestedMessages);
+ setLoading(false);
+ }
+ fetchWorkspace();
+ }, [slug]);
+
+ const handleSaveSuggestedMessages = async () => {
+ const validMessages = suggestedMessages.filter(
+ (msg) =>
+ msg?.heading?.trim()?.length > 0 || msg?.message?.trim()?.length > 0
+ );
+ const { success, error } = await Workspace.setSuggestedMessages(
+ slug,
+ validMessages
+ );
+ if (!success) {
+ showToast(`Failed to update welcome messages: ${error}`, "error");
+ return;
+ }
+ showToast("Successfully updated welcome messages.", "success");
+ setHasChanges(false);
+ };
+
+ const addMessage = () => {
+ setEditingIndex(-1);
+ if (suggestedMessages.length >= 4) {
+ showToast("Maximum of 4 messages allowed.", "warning");
+ return;
+ }
+ const defaultMessage = {
+ heading: t("general.message.heading"),
+ message: t("general.message.body"),
+ };
+ setNewMessage(defaultMessage);
+ setSuggestedMessages([...suggestedMessages, { ...defaultMessage }]);
+ setHasChanges(true);
+ };
+
+ const removeMessage = (index) => {
+ const messages = [...suggestedMessages];
+ messages.splice(index, 1);
+ setSuggestedMessages(messages);
+ setHasChanges(true);
+ };
+
+ const startEditing = (e, index) => {
+ e.preventDefault();
+ setEditingIndex(index);
+ setNewMessage({ ...suggestedMessages[index] });
+ };
+
+ const handleRemoveMessage = (index) => {
+ removeMessage(index);
+ setEditingIndex(-1);
+ };
+
+ const onEditChange = (e) => {
+ const updatedNewMessage = {
+ ...newMessage,
+ [e.target.name]: e.target.value,
+ };
+ setNewMessage(updatedNewMessage);
+ const updatedMessages = suggestedMessages.map((message, index) => {
+ if (index === editingIndex) {
+ return { ...message, [e.target.name]: e.target.value };
+ }
+ return message;
+ });
+
+ setSuggestedMessages(updatedMessages);
+ setHasChanges(true);
+ };
+
+ if (loading)
+ return (
+
+
+ {t("general.message.title")}
+
+
+ {t("general.message.description")}
+
+
+
+ );
+ return (
+
+
+
+ {t("general.message.title")}
+
+
+ {t("general.message.description")}
+
+
+
+
+ {suggestedMessages.map((suggestion, index) => (
+
+
handleRemoveMessage(index)}
+ >
+
+
+
startEditing(e, index)}
+ className={`text-left p-2.5 border rounded-xl w-full border-white/20 bg-theme-settings-input-bg hover:bg-theme-sidebar-item-selected-gradient ${
+ editingIndex === index ? "border-sky-400" : ""
+ }`}
+ >
+ {suggestion.heading}
+ {suggestion.message}
+
+
+ ))}
+
+ {editingIndex >= 0 && (
+
+ )}
+ {suggestedMessages.length < 4 && (
+
+ {t("general.message.add")}{" "}
+
+
+ )}
+
+ {hasChanges && (
+
+
+ {t("general.message.save")}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspaceName/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspaceName/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..98e6e90519f6f45471052dfd50040c4817af7962
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspaceName/index.jsx
@@ -0,0 +1,29 @@
+import { useTranslation } from "react-i18next";
+
+export default function WorkspaceName({ workspace, setHasChanges }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("common.workspaces-name")}
+
+
+ {t("general.names.description")}
+
+
+
setHasChanges(true)}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspacePfp/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspacePfp/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..fb62cb57c75ac2fa0ce42e512a6123de092ea669
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/WorkspacePfp/index.jsx
@@ -0,0 +1,97 @@
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+import { Plus } from "@phosphor-icons/react";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export default function WorkspacePfp({ workspace, slug }) {
+ const [pfp, setPfp] = useState(null);
+ const { t } = useTranslation();
+ useEffect(() => {
+ async function fetchWorkspace() {
+ const pfpUrl = await Workspace.fetchPfp(slug);
+ setPfp(pfpUrl);
+ }
+ fetchWorkspace();
+ }, [slug]);
+
+ const handleFileUpload = async (event) => {
+ const file = event.target.files[0];
+ if (!file) return false;
+
+ const formData = new FormData();
+ formData.append("file", file);
+ const { success, error } = await Workspace.uploadPfp(
+ formData,
+ workspace.slug
+ );
+ if (!success) {
+ showToast(`Failed to upload profile picture: ${error}`, "error");
+ return;
+ }
+
+ const pfpUrl = await Workspace.fetchPfp(workspace.slug);
+ setPfp(pfpUrl);
+ showToast("Profile picture uploaded.", "success");
+ };
+
+ const handleRemovePfp = async () => {
+ const { success, error } = await Workspace.removePfp(workspace.slug);
+ if (!success) {
+ showToast(`Failed to remove profile picture: ${error}`, "error");
+ return;
+ }
+
+ setPfp(null);
+ };
+
+ return (
+
+
+
{t("general.pfp.title")}
+
+ {t("general.pfp.description")}
+
+
+
+
+
+
+ {pfp ? (
+
+ ) : (
+
+
+
+ {t("general.pfp.image")}
+
+
+ 800 x 800
+
+
+ )}
+
+ {pfp && (
+
+ {t("general.pfp.remove")}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..6b47da93cd3eb3f15883781c1cf56b6a07faebb0
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx
@@ -0,0 +1,72 @@
+import Workspace from "@/models/workspace";
+import { castToType } from "@/utils/types";
+import showToast from "@/utils/toast";
+import { useEffect, useRef, useState } from "react";
+import WorkspaceName from "./WorkspaceName";
+import SuggestedChatMessages from "./SuggestedChatMessages";
+import DeleteWorkspace from "./DeleteWorkspace";
+import WorkspacePfp from "./WorkspacePfp";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function GeneralInfo({ slug }) {
+ const [workspace, setWorkspace] = useState(null);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const formEl = useRef(null);
+
+ useEffect(() => {
+ async function fetchWorkspace() {
+ const workspace = await Workspace.bySlug(slug);
+ setWorkspace(workspace);
+ setLoading(false);
+ }
+ fetchWorkspace();
+ }, [slug]);
+
+ const handleUpdate = async (e) => {
+ setSaving(true);
+ e.preventDefault();
+ const data = {};
+ const form = new FormData(formEl.current);
+ for (var [key, value] of form.entries()) data[key] = castToType(key, value);
+ const { workspace: updatedWorkspace, message } = await Workspace.update(
+ workspace.slug,
+ data
+ );
+ if (!!updatedWorkspace) {
+ showToast("Workspace updated!", "success", { clear: true });
+ } else {
+ showToast(`Error: ${message}`, "error", { clear: true });
+ }
+ setSaving(false);
+ setHasChanges(false);
+ };
+
+ if (!workspace || loading) return null;
+ return (
+
+
+ {hasChanges && (
+
+
+ {saving ? "Updating..." : "Update Workspace"}
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/Members/AddMemberModal/index.jsx b/frontend/src/pages/WorkspaceSettings/Members/AddMemberModal/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a48f26cfca4d4e55b5cb170dfa0a3ea51e9f4333
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/Members/AddMemberModal/index.jsx
@@ -0,0 +1,163 @@
+import React, { useState } from "react";
+import { MagnifyingGlass, X } from "@phosphor-icons/react";
+import Admin from "@/models/admin";
+import showToast from "@/utils/toast";
+
+export default function AddMemberModal({ closeModal, workspace, users }) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedUsers, setSelectedUsers] = useState(workspace?.userIds || []);
+
+ const handleUpdate = async (e) => {
+ e.preventDefault();
+ const { success, error } = await Admin.updateUsersInWorkspace(
+ workspace.id,
+ selectedUsers
+ );
+ if (success) {
+ showToast("Users updated successfully.", "success");
+ setTimeout(() => {
+ window.location.reload();
+ }, 1000);
+ }
+ showToast(error, "error");
+ };
+
+ const handleUserSelect = (userId) => {
+ setSelectedUsers((prevSelectedUsers) => {
+ if (prevSelectedUsers.includes(userId)) {
+ return prevSelectedUsers.filter((id) => id !== userId);
+ } else {
+ return [...prevSelectedUsers, userId];
+ }
+ });
+ };
+
+ const handleSelectAll = () => {
+ if (selectedUsers.length === filteredUsers.length) {
+ setSelectedUsers([]);
+ } else {
+ setSelectedUsers(filteredUsers.map((user) => user.id));
+ }
+ };
+
+ const handleUnselect = () => {
+ setSelectedUsers([]);
+ };
+
+ const isUserSelected = (userId) => {
+ return selectedUsers.includes(userId);
+ };
+
+ const handleSearch = (event) => {
+ setSearchTerm(event.target.value);
+ };
+
+ const filteredUsers = users
+ .filter((user) =>
+ user.username.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ .filter((user) => user.role !== "admin")
+ .filter((user) => user.role !== "manager");
+
+ return (
+
+
+
+
+
+
+ {filteredUsers.length > 0 ? (
+ filteredUsers.map((user) => (
+ handleUserSelect(user.id)}
+ >
+
+ {isUserSelected(user.id) && (
+
+ )}
+
+
+ {user.username}
+
+
+ ))
+ ) : (
+
+ No users found
+
+ )}
+
+
+
+
+
+
+ {selectedUsers.length === filteredUsers.length && (
+
+ )}
+
+ Select All
+
+ {selectedUsers.length > 0 && (
+
+
+ Unselect
+
+
+ )}
+
+
+ Save
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/Members/WorkspaceMemberRow/index.jsx b/frontend/src/pages/WorkspaceSettings/Members/WorkspaceMemberRow/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..306488ccb394701d63946942d5ad629caff13446
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/Members/WorkspaceMemberRow/index.jsx
@@ -0,0 +1,15 @@
+import { titleCase } from "text-case";
+
+export default function WorkspaceMemberRow({ user }) {
+ return (
+ <>
+
+
+ {user.username}
+
+ {titleCase(user.role)}
+ {user.lastUpdatedAt}
+
+ >
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/Members/index.jsx b/frontend/src/pages/WorkspaceSettings/Members/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d7f8bea941d9cbe83594f348985b25e5d57c85e4
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/Members/index.jsx
@@ -0,0 +1,91 @@
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import Admin from "@/models/admin";
+import { useEffect, useState } from "react";
+import * as Skeleton from "react-loading-skeleton";
+import AddMemberModal from "./AddMemberModal";
+import WorkspaceMemberRow from "./WorkspaceMemberRow";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function Members({ workspace }) {
+ const [loading, setLoading] = useState(true);
+ const [users, setUsers] = useState([]);
+ const [workspaceUsers, setWorkspaceUsers] = useState([]);
+ const [adminWorkspace, setAdminWorkspace] = useState(null);
+
+ const { isOpen, openModal, closeModal } = useModal();
+ useEffect(() => {
+ async function fetchData() {
+ const _users = await Admin.users();
+ const workspaceUsers = await Admin.workspaceUsers(workspace.id);
+ const adminWorkspaces = await Admin.workspaces();
+ setAdminWorkspace(
+ adminWorkspaces.find(
+ (adminWorkspace) => adminWorkspace.id === workspace.id
+ )
+ );
+ setWorkspaceUsers(workspaceUsers);
+ setUsers(_users);
+ setLoading(false);
+ }
+ fetchData();
+ }, [workspace]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Username
+
+
+ Role
+
+
+ Date Added
+
+
+ {" "}
+
+
+
+
+ {workspaceUsers.length > 0 ? (
+ workspaceUsers.map((user, index) => (
+
+ ))
+ ) : (
+
+
+ No workspace members
+
+
+ )}
+
+
+
Manage Users
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/VectorDatabase/DocumentSimilarityThreshold/index.jsx b/frontend/src/pages/WorkspaceSettings/VectorDatabase/DocumentSimilarityThreshold/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b0b82bd2498f447fdab8232bf0825192cef06542
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/VectorDatabase/DocumentSimilarityThreshold/index.jsx
@@ -0,0 +1,32 @@
+import { useTranslation } from "react-i18next";
+
+export default function DocumentSimilarityThreshold({
+ workspace,
+ setHasChanges,
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("vector-workspace.doc.title")}
+
+
+ {t("vector-workspace.doc.description")}
+
+
+
setHasChanges(true)}
+ required={true}
+ >
+ {t("vector-workspace.doc.zero")}
+ {t("vector-workspace.doc.low")}
+ {t("vector-workspace.doc.medium")}
+ {t("vector-workspace.doc.high")}
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/VectorDatabase/MaxContextSnippets/index.jsx b/frontend/src/pages/WorkspaceSettings/VectorDatabase/MaxContextSnippets/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9f116bab01635129c5500d79af15c2bc4d841f45
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/VectorDatabase/MaxContextSnippets/index.jsx
@@ -0,0 +1,33 @@
+import { useTranslation } from "react-i18next";
+
+export default function MaxContextSnippets({ workspace, setHasChanges }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t("vector-workspace.snippets.title")}
+
+
+ {t("vector-workspace.snippets.description")}
+
+ {t("vector-workspace.snippets.recommend")}
+
+
+
e.target.blur()}
+ defaultValue={workspace?.topN ?? 4}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5 mt-2"
+ placeholder="4"
+ required={true}
+ autoComplete="off"
+ onChange={() => setHasChanges(true)}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/VectorDatabase/ResetDatabase/index.jsx b/frontend/src/pages/WorkspaceSettings/VectorDatabase/ResetDatabase/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..58796ce96d835dc3fc0f348769b8fefd26e642ac
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/VectorDatabase/ResetDatabase/index.jsx
@@ -0,0 +1,48 @@
+import { useState } from "react";
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+import { useTranslation } from "react-i18next";
+
+export default function ResetDatabase({ workspace }) {
+ const [deleting, setDeleting] = useState(false);
+ const { t } = useTranslation();
+ const resetVectorDatabase = async () => {
+ if (!window.confirm(`${t("vector-workspace.reset.confirm")}`)) return false;
+
+ setDeleting(true);
+ const success = await Workspace.wipeVectorDb(workspace.slug);
+ if (!success) {
+ showToast(
+ t("vector-workspace.reset.error"),
+ t("vector-workspace.common.error"),
+ {
+ clear: true,
+ }
+ );
+ setDeleting(false);
+ return;
+ }
+
+ showToast(
+ t("vector-workspace.reset.success"),
+ t("vector-workspace.common.success"),
+ {
+ clear: true,
+ }
+ );
+ setDeleting(false);
+ };
+
+ return (
+
+ {deleting
+ ? t("vector-workspace.reset.resetting")
+ : t("vector-workspace.reset.reset")}
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorCount/index.jsx b/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorCount/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8a7dceefcd2f1e1092a4018b2c8f564426222469
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorCount/index.jsx
@@ -0,0 +1,38 @@
+import PreLoader from "@/components/Preloader";
+import System from "@/models/system";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export default function VectorCount({ reload, workspace }) {
+ const [totalVectors, setTotalVectors] = useState(null);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ async function fetchVectorCount() {
+ const totalVectors = await System.totalIndexes(workspace.slug);
+ setTotalVectors(totalVectors);
+ }
+ fetchVectorCount();
+ }, [workspace?.slug, reload]);
+
+ if (totalVectors === null)
+ return (
+
+
{t("general.vector.title")}
+
+ {t("general.vector.description")}
+
+
+
+ );
+ return (
+
+
{t("general.vector.title")}
+
+ {totalVectors}
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorDBIdentifier/index.jsx b/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorDBIdentifier/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..1101299426c6568d0e4545286ec074bee42d6167
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorDBIdentifier/index.jsx
@@ -0,0 +1,12 @@
+import { useTranslation } from "react-i18next";
+
+export default function VectorDBIdentifier({ workspace }) {
+ const { t } = useTranslation();
+ return (
+
+
{t("vector-workspace.identifier")}
+
+
{workspace?.slug}
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorSearchMode/index.jsx b/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorSearchMode/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..5e5816cda8d2d3c7dc2097d4aa2da6a4ffadaef2
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/VectorDatabase/VectorSearchMode/index.jsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+
+// We dont support all vectorDBs yet for reranking due to complexities of how each provider
+// returns information. We need to normalize the response data so Reranker can be used for each provider.
+const supportedVectorDBs = ["lancedb"];
+const hint = {
+ default: {
+ title: "Default",
+ description:
+ "This is the fastest performance, but may not return the most relevant results leading to model hallucinations.",
+ },
+ rerank: {
+ title: "Accuracy Optimized",
+ description:
+ "LLM responses may take longer to generate, but your responses will be more accurate and relevant.",
+ },
+};
+
+export default function VectorSearchMode({ workspace, setHasChanges }) {
+ const [selection, setSelection] = useState(
+ workspace?.vectorSearchMode ?? "default"
+ );
+ if (!workspace?.vectorDB || !supportedVectorDBs.includes(workspace?.vectorDB))
+ return null;
+
+ return (
+
+
+
+ Search Preference
+
+
+
{
+ setSelection(e.target.value);
+ setHasChanges(true);
+ }}
+ required={true}
+ >
+ Default
+ Accuracy Optimized
+
+
+ {hint[selection]?.description}
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/VectorDatabase/index.jsx b/frontend/src/pages/WorkspaceSettings/VectorDatabase/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a780669278c628312dfd04d3cf7eef7e783e2856
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/VectorDatabase/index.jsx
@@ -0,0 +1,69 @@
+import Workspace from "@/models/workspace";
+import showToast from "@/utils/toast";
+import { castToType } from "@/utils/types";
+import { useRef, useState } from "react";
+import VectorDBIdentifier from "./VectorDBIdentifier";
+import MaxContextSnippets from "./MaxContextSnippets";
+import DocumentSimilarityThreshold from "./DocumentSimilarityThreshold";
+import ResetDatabase from "./ResetDatabase";
+import VectorCount from "./VectorCount";
+import VectorSearchMode from "./VectorSearchMode";
+import CTAButton from "@/components/lib/CTAButton";
+
+export default function VectorDatabase({ workspace }) {
+ const [hasChanges, setHasChanges] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const formEl = useRef(null);
+
+ const handleUpdate = async (e) => {
+ setSaving(true);
+ e.preventDefault();
+ const data = {};
+ const form = new FormData(formEl.current);
+ for (var [key, value] of form.entries()) data[key] = castToType(key, value);
+ const { workspace: updatedWorkspace, message } = await Workspace.update(
+ workspace.slug,
+ data
+ );
+ if (!!updatedWorkspace) {
+ showToast("Workspace updated!", "success", { clear: true });
+ } else {
+ showToast(`Error: ${message}`, "error", { clear: true });
+ }
+ setSaving(false);
+ setHasChanges(false);
+ };
+
+ if (!workspace) return null;
+ return (
+
+
+ {hasChanges && (
+
+
+ {saving ? "Updating..." : "Update Workspace"}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkspaceSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/index.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bf250286aec0d9a596926667f41f8c1a7452c707
--- /dev/null
+++ b/frontend/src/pages/WorkspaceSettings/index.jsx
@@ -0,0 +1,144 @@
+import React, { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import Sidebar from "@/components/Sidebar";
+import Workspace from "@/models/workspace";
+import PasswordModal, { usePasswordModal } from "@/components/Modals/Password";
+import { isMobile } from "react-device-detect";
+import { FullScreenLoader } from "@/components/Preloader";
+import {
+ ArrowUUpLeft,
+ ChatText,
+ Database,
+ Robot,
+ User,
+ Wrench,
+} from "@phosphor-icons/react";
+import paths from "@/utils/paths";
+import { Link } from "react-router-dom";
+import { NavLink } from "react-router-dom";
+import GeneralAppearance from "./GeneralAppearance";
+import ChatSettings from "./ChatSettings";
+import VectorDatabase from "./VectorDatabase";
+import Members from "./Members";
+import WorkspaceAgentConfiguration from "./AgentConfig";
+import useUser from "@/hooks/useUser";
+import { useTranslation } from "react-i18next";
+import System from "@/models/system";
+
+const TABS = {
+ "general-appearance": GeneralAppearance,
+ "chat-settings": ChatSettings,
+ "vector-database": VectorDatabase,
+ members: Members,
+ "agent-config": WorkspaceAgentConfiguration,
+};
+
+export default function WorkspaceSettings() {
+ const { loading, requiresAuth, mode } = usePasswordModal();
+
+ if (loading) return
;
+ if (requiresAuth !== false) {
+ return <>{requiresAuth !== null &&
}>;
+ }
+
+ return
;
+}
+
+function ShowWorkspaceChat() {
+ const { t } = useTranslation();
+ const { slug, tab } = useParams();
+ const { user } = useUser();
+ const [workspace, setWorkspace] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function getWorkspace() {
+ if (!slug) return;
+ const _workspace = await Workspace.bySlug(slug);
+ if (!_workspace) {
+ setLoading(false);
+ return;
+ }
+
+ const _settings = await System.keys();
+ const suggestedMessages = await Workspace.getSuggestedMessages(slug);
+ setWorkspace({
+ ..._workspace,
+ vectorDB: _settings?.VectorDB,
+ suggestedMessages,
+ });
+ setLoading(false);
+ }
+ getWorkspace();
+ }, [slug, tab]);
+
+ if (loading) return
;
+
+ const TabContent = TABS[tab];
+ return (
+
+ {!isMobile &&
}
+
+
+
+
+
+
}
+ to={paths.workspace.settings.generalAppearance(slug)}
+ />
+
}
+ to={paths.workspace.settings.chatSettings(slug)}
+ />
+
}
+ to={paths.workspace.settings.vectorDatabase(slug)}
+ />
+
}
+ to={paths.workspace.settings.members(slug)}
+ visible={["admin", "manager"].includes(user?.role)}
+ />
+
}
+ to={paths.workspace.settings.agentConfig(slug)}
+ />
+
+
+
+
+
+
+ );
+}
+
+function TabItem({ title, icon, to, visible = true }) {
+ if (!visible) return null;
+ return (
+
+ `${
+ isActive
+ ? "text-sky-400 pb-4 border-b-[4px] -mb-[19px] border-sky-400"
+ : "text-white/60 hover:text-sky-400"
+ } ` + " flex gap-x-2 items-center font-medium"
+ }
+ >
+ {icon}
+ {title}
+
+ );
+}
diff --git a/frontend/src/utils/chat/agent.js b/frontend/src/utils/chat/agent.js
new file mode 100644
index 0000000000000000000000000000000000000000..ad1193d304c953fe0d9095d99791bbab5f5fc2b4
--- /dev/null
+++ b/frontend/src/utils/chat/agent.js
@@ -0,0 +1,123 @@
+import { v4 } from "uuid";
+import { safeJsonParse } from "../request";
+import { saveAs } from "file-saver";
+import { API_BASE } from "../constants";
+import { useEffect, useState } from "react";
+
+export const AGENT_SESSION_START = "agentSessionStart";
+export const AGENT_SESSION_END = "agentSessionEnd";
+const handledEvents = [
+ "statusResponse",
+ "fileDownload",
+ "awaitingFeedback",
+ "wssFailure",
+ "rechartVisualize",
+];
+
+export function websocketURI() {
+ const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ if (API_BASE === "/api") return `${wsProtocol}//${window.location.host}`;
+ return `${wsProtocol}//${new URL(import.meta.env.VITE_API_BASE).host}`;
+}
+
+export default function handleSocketResponse(event, setChatHistory) {
+ const data = safeJsonParse(event.data, null);
+ if (data === null) return;
+
+ // No message type is defined then this is a generic message
+ // that we need to print to the user as a system response
+ if (!data.hasOwnProperty("type")) {
+ return setChatHistory((prev) => {
+ return [
+ ...prev.filter((msg) => !!msg.content),
+ {
+ uuid: v4(),
+ content: data.content,
+ role: "assistant",
+ sources: [],
+ closed: true,
+ error: null,
+ animate: false,
+ pending: false,
+ },
+ ];
+ });
+ }
+
+ if (!handledEvents.includes(data.type) || !data.content) return;
+
+ if (data.type === "fileDownload") {
+ saveAs(data.content.b64Content, data.content.filename ?? "unknown.txt");
+ return;
+ }
+
+ if (data.type === "rechartVisualize") {
+ return setChatHistory((prev) => {
+ return [
+ ...prev.filter((msg) => !!msg.content),
+ {
+ type: "rechartVisualize",
+ uuid: v4(),
+ content: data.content,
+ role: "assistant",
+ sources: [],
+ closed: true,
+ error: null,
+ animate: false,
+ pending: false,
+ },
+ ];
+ });
+ }
+
+ if (data.type === "wssFailure") {
+ return setChatHistory((prev) => {
+ return [
+ ...prev.filter((msg) => !!msg.content),
+ {
+ uuid: v4(),
+ content: data.content,
+ role: "assistant",
+ sources: [],
+ closed: true,
+ error: data.content,
+ animate: false,
+ pending: false,
+ },
+ ];
+ });
+ }
+
+ return setChatHistory((prev) => {
+ return [
+ ...prev.filter((msg) => !!msg.content),
+ {
+ uuid: v4(),
+ type: data.type,
+ content: data.content,
+ role: "assistant",
+ sources: [],
+ closed: true,
+ error: null,
+ animate: data?.animate || false,
+ pending: false,
+ },
+ ];
+ });
+}
+
+export function useIsAgentSessionActive() {
+ const [activeSession, setActiveSession] = useState(false);
+ useEffect(() => {
+ function listenForAgentSession() {
+ if (!window) return;
+ window.addEventListener(AGENT_SESSION_START, () =>
+ setActiveSession(true)
+ );
+ window.addEventListener(AGENT_SESSION_END, () => setActiveSession(false));
+ }
+ listenForAgentSession();
+ }, []);
+
+ return activeSession;
+}
diff --git a/frontend/src/utils/chat/index.js b/frontend/src/utils/chat/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..bfabcbeacbf3d6844e0752d9b671e51d7df6c649
--- /dev/null
+++ b/frontend/src/utils/chat/index.js
@@ -0,0 +1,194 @@
+import { THREAD_RENAME_EVENT } from "@/components/Sidebar/ActiveWorkspaces/ThreadContainer";
+import { emitAssistantMessageCompleteEvent } from "@/components/contexts/TTSProvider";
+export const ABORT_STREAM_EVENT = "abort-chat-stream";
+
+// For handling of chat responses in the frontend by their various types.
+export default function handleChat(
+ chatResult,
+ setLoadingResponse,
+ setChatHistory,
+ remHistory,
+ _chatHistory,
+ setWebsocket
+) {
+ const {
+ uuid,
+ textResponse,
+ type,
+ sources = [],
+ error,
+ close,
+ animate = false,
+ chatId = null,
+ action = null,
+ metrics = {},
+ } = chatResult;
+
+ if (type === "abort" || type === "statusResponse") {
+ setLoadingResponse(false);
+ setChatHistory([
+ ...remHistory,
+ {
+ type,
+ uuid,
+ content: textResponse,
+ role: "assistant",
+ sources,
+ closed: true,
+ error,
+ animate,
+ pending: false,
+ metrics,
+ },
+ ]);
+ _chatHistory.push({
+ type,
+ uuid,
+ content: textResponse,
+ role: "assistant",
+ sources,
+ closed: true,
+ error,
+ animate,
+ pending: false,
+ metrics,
+ });
+ } else if (type === "textResponse") {
+ setLoadingResponse(false);
+ setChatHistory([
+ ...remHistory,
+ {
+ uuid,
+ content: textResponse,
+ role: "assistant",
+ sources,
+ closed: close,
+ error,
+ animate: !close,
+ pending: false,
+ chatId,
+ metrics,
+ },
+ ]);
+ _chatHistory.push({
+ uuid,
+ content: textResponse,
+ role: "assistant",
+ sources,
+ closed: close,
+ error,
+ animate: !close,
+ pending: false,
+ chatId,
+ metrics,
+ });
+ emitAssistantMessageCompleteEvent(chatId);
+ } else if (
+ type === "textResponseChunk" ||
+ type === "finalizeResponseStream"
+ ) {
+ const chatIdx = _chatHistory.findIndex((chat) => chat.uuid === uuid);
+ if (chatIdx !== -1) {
+ const existingHistory = { ..._chatHistory[chatIdx] };
+ let updatedHistory;
+
+ // If the response is finalized, we can set the loading state to false.
+ // and append the metrics to the history.
+ if (type === "finalizeResponseStream") {
+ updatedHistory = {
+ ...existingHistory,
+ closed: close,
+ animate: !close,
+ pending: false,
+ chatId,
+ metrics,
+ };
+
+ _chatHistory[chatIdx - 1] = { ..._chatHistory[chatIdx - 1], chatId }; // update prompt with chatID
+
+ emitAssistantMessageCompleteEvent(chatId);
+ setLoadingResponse(false);
+ } else {
+ updatedHistory = {
+ ...existingHistory,
+ content: existingHistory.content + textResponse,
+ sources,
+ error,
+ closed: close,
+ animate: !close,
+ pending: false,
+ chatId,
+ metrics,
+ };
+ }
+ _chatHistory[chatIdx] = updatedHistory;
+ } else {
+ _chatHistory.push({
+ uuid,
+ sources,
+ error,
+ content: textResponse,
+ role: "assistant",
+ closed: close,
+ animate: !close,
+ pending: false,
+ chatId,
+ metrics,
+ });
+ }
+ setChatHistory([..._chatHistory]);
+ } else if (type === "agentInitWebsocketConnection") {
+ setWebsocket(chatResult.websocketUUID);
+ } else if (type === "stopGeneration") {
+ const chatIdx = _chatHistory.length - 1;
+ const existingHistory = { ..._chatHistory[chatIdx] };
+ const updatedHistory = {
+ ...existingHistory,
+ sources: [],
+ closed: true,
+ error: null,
+ animate: false,
+ pending: false,
+ metrics,
+ };
+ _chatHistory[chatIdx] = updatedHistory;
+
+ setChatHistory([..._chatHistory]);
+ setLoadingResponse(false);
+ }
+
+ // Action Handling via special 'action' attribute on response.
+ if (action === "reset_chat") {
+ // Chat was reset, keep reset message and clear everything else.
+ setChatHistory([_chatHistory.pop()]);
+ }
+
+ // If thread was updated automatically based on chat prompt
+ // then we can handle the updating of the thread here.
+ if (action === "rename_thread") {
+ if (!!chatResult?.thread?.slug && chatResult.thread.name) {
+ window.dispatchEvent(
+ new CustomEvent(THREAD_RENAME_EVENT, {
+ detail: {
+ threadSlug: chatResult.thread.slug,
+ newName: chatResult.thread.name,
+ },
+ })
+ );
+ }
+ }
+}
+
+export function chatPrompt(workspace) {
+ return (
+ workspace?.openAiPrompt ??
+ "Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed."
+ );
+}
+
+export function chatQueryRefusalResponse(workspace) {
+ return (
+ workspace?.queryRefusalResponse ??
+ "There is no relevant information in this workspace to answer your query."
+ );
+}
diff --git a/frontend/src/utils/chat/markdown.js b/frontend/src/utils/chat/markdown.js
new file mode 100644
index 0000000000000000000000000000000000000000..015d66ab1d379d6af13a42238e9d588f8f573320
--- /dev/null
+++ b/frontend/src/utils/chat/markdown.js
@@ -0,0 +1,78 @@
+import { encode as HTMLEncode } from "he";
+import markdownIt from "markdown-it";
+import markdownItKatexPlugin from "./plugins/markdown-katex";
+import hljs from "highlight.js";
+import "./themes/github-dark.css";
+import "./themes/github.css";
+import { v4 } from "uuid";
+
+const markdown = markdownIt({
+ html: false,
+ typographer: true,
+ highlight: function (code, lang) {
+ const uuid = v4();
+ const theme =
+ window.localStorage.getItem("theme") === "light"
+ ? "github"
+ : "github-dark";
+
+ if (lang && hljs.getLanguage(lang)) {
+ try {
+ return (
+ `
+
+
+ ${lang || ""}
+
+
+
+ Copy block
+
+
+
` +
+ hljs.highlight(code, { language: lang, ignoreIllegals: true }).value +
+ " "
+ );
+ } catch (__) {}
+ }
+
+ return (
+ `
+
+
` +
+ HTMLEncode(code) +
+ " "
+ );
+ },
+});
+
+// Add custom renderer for strong tags to handle theme colors
+markdown.renderer.rules.strong_open = () => '
';
+markdown.renderer.rules.strong_close = () => " ";
+markdown.renderer.rules.link_open = (tokens, idx) => {
+ const token = tokens[idx];
+ const href = token.attrs.find((attr) => attr[0] === "href");
+ return `
`;
+};
+
+// Custom renderer for responsive images rendered in markdown
+markdown.renderer.rules.image = function (tokens, idx) {
+ const token = tokens[idx];
+ const srcIndex = token.attrIndex("src");
+ const src = token.attrs[srcIndex][1];
+ const alt = token.content || "";
+
+ return ``;
+};
+
+markdown.use(markdownItKatexPlugin);
+
+export default function renderMarkdown(text = "") {
+ return markdown.render(text);
+}
diff --git a/frontend/src/utils/chat/plugins/markdown-katex.js b/frontend/src/utils/chat/plugins/markdown-katex.js
new file mode 100644
index 0000000000000000000000000000000000000000..76fb940a7e1623b47e7fb3f1d93f060d6aea8a00
--- /dev/null
+++ b/frontend/src/utils/chat/plugins/markdown-katex.js
@@ -0,0 +1,277 @@
+import katex from "katex";
+
+// Test if potential opening or closing delimieter
+// Assumes that there is a "$" at state.src[pos]
+function isValidDelim(state, pos) {
+ var prevChar,
+ nextChar,
+ max = state.posMax,
+ can_open = true,
+ can_close = true;
+
+ prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1;
+ nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1;
+
+ // Only apply whitespace rules if we're dealing with $ delimiter
+ if (state.src[pos] === "$") {
+ if (
+ prevChar === 0x20 /* " " */ ||
+ prevChar === 0x09 /* \t */ ||
+ (nextChar >= 0x30 /* "0" */ && nextChar <= 0x39) /* "9" */
+ ) {
+ can_close = false;
+ }
+ if (nextChar === 0x20 /* " " */ || nextChar === 0x09 /* \t */) {
+ can_open = false;
+ }
+ }
+
+ return {
+ can_open: can_open,
+ can_close: can_close,
+ };
+}
+
+function math_inline(state, silent) {
+ var start, match, token, res, pos, esc_count;
+
+ // Only process $ and \( delimiters for inline math
+ if (
+ state.src[state.pos] !== "$" &&
+ (state.src[state.pos] !== "\\" || state.src[state.pos + 1] !== "(")
+ ) {
+ return false;
+ }
+
+ // Handle \( ... \) case separately
+ if (state.src[state.pos] === "\\" && state.src[state.pos + 1] === "(") {
+ start = state.pos + 2;
+ match = start;
+ while ((match = state.src.indexOf("\\)", match)) !== -1) {
+ pos = match - 1;
+ while (state.src[pos] === "\\") {
+ pos -= 1;
+ }
+ if ((match - pos) % 2 == 1) {
+ break;
+ }
+ match += 1;
+ }
+
+ if (match === -1) {
+ if (!silent) {
+ state.pending += "\\(";
+ }
+ state.pos = start;
+ return true;
+ }
+
+ if (!silent) {
+ token = state.push("math_inline", "math", 0);
+ token.markup = "\\(";
+ token.content = state.src.slice(start, match);
+ }
+
+ state.pos = match + 2;
+ return true;
+ }
+
+ res = isValidDelim(state, state.pos);
+ if (!res.can_open) {
+ if (!silent) {
+ state.pending += "$";
+ }
+ state.pos += 1;
+ return true;
+ }
+
+ // First check for and bypass all properly escaped delimieters
+ // This loop will assume that the first leading backtick can not
+ // be the first character in state.src, which is known since
+ // we have found an opening delimieter already.
+ start = state.pos + 1;
+ match = start;
+ while ((match = state.src.indexOf("$", match)) !== -1) {
+ // Found potential $, look for escapes, pos will point to
+ // first non escape when complete
+ pos = match - 1;
+ while (state.src[pos] === "\\") {
+ pos -= 1;
+ }
+
+ // Even number of escapes, potential closing delimiter found
+ if ((match - pos) % 2 == 1) {
+ break;
+ }
+ match += 1;
+ }
+
+ // No closing delimiter found. Consume $ and continue.
+ if (match === -1) {
+ if (!silent) {
+ state.pending += "$";
+ }
+ state.pos = start;
+ return true;
+ }
+
+ // Check if we have empty content, ie: $$. Do not parse.
+ if (match - start === 0) {
+ if (!silent) {
+ state.pending += "$$";
+ }
+ state.pos = start + 1;
+ return true;
+ }
+
+ // Check for valid closing delimiter
+ res = isValidDelim(state, match);
+ if (!res.can_close) {
+ if (!silent) {
+ state.pending += "$";
+ }
+ state.pos = start;
+ return true;
+ }
+
+ if (!silent) {
+ token = state.push("math_inline", "math", 0);
+ token.markup = "$";
+ token.content = state.src.slice(start, match);
+ }
+
+ state.pos = match + 1;
+ return true;
+}
+
+function math_block(state, start, end, silent) {
+ var firstLine,
+ lastLine,
+ next,
+ lastPos,
+ found = false,
+ token,
+ pos = state.bMarks[start] + state.tShift[start],
+ max = state.eMarks[start];
+
+ // Check for $$, \[, or standalone [ as opening delimiters
+ if (pos + 1 > max) {
+ return false;
+ }
+
+ let openDelim = state.src.slice(pos, pos + 2);
+ let isDoubleDollar = openDelim === "$$";
+ let isLatexBracket = openDelim === "\\[";
+
+ if (!isDoubleDollar && !isLatexBracket) {
+ return false;
+ }
+
+ // Determine the closing delimiter and position adjustment
+ let delimiter, posAdjust;
+ if (isDoubleDollar) {
+ delimiter = "$$";
+ posAdjust = 2;
+ } else if (isLatexBracket) {
+ delimiter = "\\]";
+ posAdjust = 2;
+ }
+
+ pos += posAdjust;
+ firstLine = state.src.slice(pos, max);
+
+ if (silent) {
+ return true;
+ }
+ if (firstLine.trim().slice(-delimiter.length) === delimiter) {
+ // Single line expression
+ firstLine = firstLine.trim().slice(0, -delimiter.length);
+ found = true;
+ }
+
+ for (next = start; !found; ) {
+ next++;
+
+ if (next >= end) {
+ break;
+ }
+
+ pos = state.bMarks[next] + state.tShift[next];
+ max = state.eMarks[next];
+
+ if (pos < max && state.tShift[next] < state.blkIndent) {
+ // non-empty line with negative indent should stop the list:
+ break;
+ }
+
+ if (
+ state.src.slice(pos, max).trim().slice(-delimiter.length) === delimiter
+ ) {
+ lastPos = state.src.slice(0, max).lastIndexOf(delimiter);
+ lastLine = state.src.slice(pos, lastPos);
+ found = true;
+ }
+ }
+
+ state.line = next + 1;
+
+ token = state.push("math_block", "math", 0);
+ token.block = true;
+ token.content =
+ (firstLine && firstLine.trim() ? firstLine + "\n" : "") +
+ state.getLines(start + 1, next, state.tShift[start], true) +
+ (lastLine && lastLine.trim() ? lastLine : "");
+ token.map = [start, state.line];
+ token.markup = delimiter;
+ return true;
+}
+
+export default function math_plugin(md, options) {
+ // Default options
+ options = options || {};
+
+ var katexInline = function (latex) {
+ options.displayMode = false;
+ try {
+ latex = latex
+ .replace(/^\[(.*)\]$/, "$1")
+ .replace(/^\\\((.*)\\\)$/, "$1")
+ .replace(/^\\\[(.*)\\\]$/, "$1");
+ return katex.renderToString(latex, options);
+ } catch (error) {
+ if (options.throwOnError) {
+ console.log(error);
+ }
+ return latex;
+ }
+ };
+
+ var inlineRenderer = function (tokens, idx) {
+ return katexInline(tokens[idx].content);
+ };
+
+ var katexBlock = function (latex) {
+ options.displayMode = true;
+ try {
+ // Remove surrounding delimiters if present
+ latex = latex.replace(/^\[(.*)\]$/, "$1").replace(/^\\\[(.*)\\\]$/, "$1");
+ return "" + katex.renderToString(latex, options) + "
";
+ } catch (error) {
+ if (options.throwOnError) {
+ console.log(error);
+ }
+ return latex;
+ }
+ };
+
+ var blockRenderer = function (tokens, idx) {
+ return katexBlock(tokens[idx].content) + "\n";
+ };
+
+ md.inline.ruler.after("escape", "math_inline", math_inline);
+ md.block.ruler.after("blockquote", "math_block", math_block, {
+ alt: ["paragraph", "reference", "blockquote", "list"],
+ });
+ md.renderer.rules.math_inline = inlineRenderer;
+ md.renderer.rules.math_block = blockRenderer;
+}
diff --git a/frontend/src/utils/chat/purify.js b/frontend/src/utils/chat/purify.js
new file mode 100644
index 0000000000000000000000000000000000000000..a6cf85206602c4153ff4c117c3177f4764ff8053
--- /dev/null
+++ b/frontend/src/utils/chat/purify.js
@@ -0,0 +1,8 @@
+import createDOMPurify from "dompurify";
+
+const DOMPurify = createDOMPurify(window);
+DOMPurify.setConfig({
+ ADD_ATTR: ["target", "rel"],
+});
+
+export default DOMPurify;
diff --git a/frontend/src/utils/chat/themes/github-dark.css b/frontend/src/utils/chat/themes/github-dark.css
new file mode 100644
index 0000000000000000000000000000000000000000..3badb687522928426316bf4dff26598c1efcdc0e
--- /dev/null
+++ b/frontend/src/utils/chat/themes/github-dark.css
@@ -0,0 +1,125 @@
+/*!
+ Theme: GitHub Dark
+ Description: Dark theme as seen on github.com
+ Author: github.com
+ Maintainer: @Hirse
+ Updated: 2021-05-15
+
+ Outdated base version: https://github.com/primer/github-syntax-dark
+ Current colors taken from GitHub's CSS
+*/
+
+.github-dark.hljs {
+ color: #c9d1d9;
+ background: #0d1117;
+}
+
+.github-dark .hljs-doctag,
+.github-dark .hljs-keyword,
+.github-dark .hljs-meta .hljs-keyword,
+.github-dark .hljs-template-tag,
+.github-dark .hljs-template-variable,
+.github-dark .hljs-type,
+.github-dark .hljs-variable.language_ {
+ /* prettylights-syntax-keyword */
+ color: #ff7b72;
+}
+
+.github-dark .hljs-title,
+.github-dark .hljs-title.class_,
+.github-dark .hljs-title.class_.inherited__,
+.github-dark .hljs-title.function_ {
+ /* prettylights-syntax-entity */
+ color: #d2a8ff;
+}
+
+.github-dark .hljs-attr,
+.github-dark .hljs-attribute,
+.github-dark .hljs-literal,
+.github-dark .hljs-meta,
+.github-dark .hljs-number,
+.github-dark .hljs-operator,
+.github-dark .hljs-variable,
+.github-dark .hljs-selector-attr,
+.github-dark .hljs-selector-class,
+.github-dark .hljs-selector-id {
+ /* prettylights-syntax-constant */
+ color: #79c0ff;
+}
+
+.github-dark .hljs-regexp,
+.github-dark .hljs-string,
+.github-dark .hljs-meta .hljs-string {
+ /* prettylights-syntax-string */
+ color: #a5d6ff;
+}
+
+.github-dark .hljs-built_in,
+.github-dark .hljs-symbol {
+ /* prettylights-syntax-variable */
+ color: #ffa657;
+}
+
+.github-dark .hljs-comment,
+.github-dark .hljs-code,
+.github-dark .hljs-formula {
+ /* prettylights-syntax-comment */
+ color: #8b949e;
+}
+
+.github-dark .hljs-name,
+.github-dark .hljs-quote,
+.github-dark .hljs-selector-tag,
+.github-dark .hljs-selector-pseudo {
+ /* prettylights-syntax-entity-tag */
+ color: #7ee787;
+}
+
+.github-dark .hljs-subst {
+ /* prettylights-syntax-storage-modifier-import */
+ color: #c9d1d9;
+}
+
+.github-dark .hljs-section {
+ /* prettylights-syntax-markup-heading */
+ color: #1f6feb;
+ font-weight: bold;
+}
+
+.github-dark .hljs-bullet {
+ /* prettylights-syntax-markup-list */
+ color: #f2cc60;
+}
+
+.github-dark .hljs-emphasis {
+ /* prettylights-syntax-markup-italic */
+ color: #c9d1d9;
+ font-style: italic;
+}
+
+.github-dark .hljs-strong {
+ /* prettylights-syntax-markup-bold */
+ color: #c9d1d9;
+ font-weight: bold;
+}
+
+.github-dark .hljs-addition {
+ /* prettylights-syntax-markup-inserted */
+ color: #aff5b4;
+ background-color: #033a16;
+}
+
+.github-dark .hljs-deletion {
+ /* prettylights-syntax-markup-deleted */
+ color: #ffdcd7;
+ background-color: #67060c;
+}
+
+.github-dark .hljs-char.escape_,
+.github-dark .hljs-link,
+.github-dark .hljs-params,
+.github-dark .hljs-property,
+.github-dark .hljs-punctuation,
+.github-dark .hljs-tag {
+ /* purposely ignored */
+}
diff --git a/frontend/src/utils/chat/themes/github.css b/frontend/src/utils/chat/themes/github.css
new file mode 100644
index 0000000000000000000000000000000000000000..e7959f937600bc310201ec24dc0fc110deb0b2bc
--- /dev/null
+++ b/frontend/src/utils/chat/themes/github.css
@@ -0,0 +1,125 @@
+/*!
+ Theme: GitHub
+ Description: Light theme as seen on github.com
+ Author: github.com
+ Maintainer: @Hirse
+ Updated: 2021-05-15
+
+ Outdated base version: https://github.com/primer/github-syntax-light
+ Current colors taken from GitHub's CSS
+*/
+
+.github.hljs {
+ color: #24292e;
+ background: #ffffff;
+}
+
+.github .hljs-doctag,
+.github .hljs-keyword,
+.github .hljs-meta .hljs-keyword,
+.github .hljs-template-tag,
+.github .hljs-template-variable,
+.github .hljs-type,
+.github .hljs-variable.language_ {
+ /* prettylights-syntax-keyword */
+ color: #d73a49;
+}
+
+.github .hljs-title,
+.github .hljs-title.class_,
+.github .hljs-title.class_.inherited__,
+.github .hljs-title.function_ {
+ /* prettylights-syntax-entity */
+ color: #6f42c1;
+}
+
+.github .hljs-attr,
+.github .hljs-attribute,
+.github .hljs-literal,
+.github .hljs-meta,
+.github .hljs-number,
+.github .hljs-operator,
+.github .hljs-variable,
+.github .hljs-selector-attr,
+.github .hljs-selector-class,
+.github .hljs-selector-id {
+ /* prettylights-syntax-constant */
+ color: #005cc5;
+}
+
+.github .hljs-regexp,
+.github .hljs-string,
+.github .hljs-meta .hljs-string {
+ /* prettylights-syntax-string */
+ color: #032f62;
+}
+
+.github .hljs-built_in,
+.github .hljs-symbol {
+ /* prettylights-syntax-variable */
+ color: #e36209;
+}
+
+.github .hljs-comment,
+.github .hljs-code,
+.github .hljs-formula {
+ /* prettylights-syntax-comment */
+ color: #6a737d;
+}
+
+.github .hljs-name,
+.github .hljs-quote,
+.github .hljs-selector-tag,
+.github .hljs-selector-pseudo {
+ /* prettylights-syntax-entity-tag */
+ color: #22863a;
+}
+
+.github .hljs-subst {
+ /* prettylights-syntax-storage-modifier-import */
+ color: #24292e;
+}
+
+.github .hljs-section {
+ /* prettylights-syntax-markup-heading */
+ color: #005cc5;
+ font-weight: bold;
+}
+
+.github .hljs-bullet {
+ /* prettylights-syntax-markup-list */
+ color: #735c0f;
+}
+
+.github .hljs-emphasis {
+ /* prettylights-syntax-markup-italic */
+ color: #24292e;
+ font-style: italic;
+}
+
+.github .hljs-strong {
+ /* prettylights-syntax-markup-bold */
+ color: #24292e;
+ font-weight: bold;
+}
+
+.github .hljs-addition {
+ /* prettylights-syntax-markup-inserted */
+ color: #22863a;
+ background-color: #f0fff4;
+}
+
+.github .hljs-deletion {
+ /* prettylights-syntax-markup-deleted */
+ color: #b31d28;
+ background-color: #ffeef0;
+}
+
+.github .hljs-char.escape_,
+.github .hljs-link,
+.github .hljs-params,
+.github .hljs-property,
+.github .hljs-punctuation,
+.github .hljs-tag {
+ /* purposely ignored */
+}
diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..c6a44d2ae30ac41225f8b00d4857cdcb14058bea
--- /dev/null
+++ b/frontend/src/utils/constants.js
@@ -0,0 +1,59 @@
+export const API_BASE = import.meta.env.VITE_API_BASE || "/api";
+export const ONBOARDING_SURVEY_URL = "https://onboarding.anythingllm.com";
+
+export const AUTH_USER = "anythingllm_user";
+export const AUTH_TOKEN = "anythingllm_authToken";
+export const AUTH_TIMESTAMP = "anythingllm_authTimestamp";
+export const COMPLETE_QUESTIONNAIRE = "anythingllm_completed_questionnaire";
+export const SEEN_DOC_PIN_ALERT = "anythingllm_pinned_document_alert";
+export const SEEN_WATCH_ALERT = "anythingllm_watched_document_alert";
+
+export const APPEARANCE_SETTINGS = "anythingllm_appearance_settings";
+
+export const OLLAMA_COMMON_URLS = [
+ "http://127.0.0.1:11434",
+ "http://host.docker.internal:11434",
+ "http://172.17.0.1:11434",
+];
+
+export const LMSTUDIO_COMMON_URLS = [
+ "http://localhost:1234/v1",
+ "http://127.0.0.1:1234/v1",
+ "http://host.docker.internal:1234/v1",
+ "http://172.17.0.1:1234/v1",
+];
+
+export const KOBOLDCPP_COMMON_URLS = [
+ "http://127.0.0.1:5000/v1",
+ "http://localhost:5000/v1",
+ "http://host.docker.internal:5000/v1",
+ "http://172.17.0.1:5000/v1",
+];
+
+export const LOCALAI_COMMON_URLS = [
+ "http://127.0.0.1:8080/v1",
+ "http://localhost:8080/v1",
+ "http://host.docker.internal:8080/v1",
+ "http://172.17.0.1:8080/v1",
+];
+
+export const DPAIS_COMMON_URLS = [
+ "http://127.0.0.1:8553/v1",
+ "http://0.0.0.0:8553/v1",
+ "http://localhost:8553/v1",
+ "http://host.docker.internal:8553/v1",
+];
+
+export const NVIDIA_NIM_COMMON_URLS = [
+ "http://127.0.0.1:8000/v1/version",
+ "http://localhost:8000/v1/version",
+ "http://host.docker.internal:8000/v1/version",
+ "http://172.17.0.1:8000/v1/version",
+];
+
+export function fullApiUrl() {
+ if (API_BASE !== "/api") return API_BASE;
+ return `${window.location.origin}/api`;
+}
+
+export const POPUP_BROWSER_EXTENSION_EVENT = "NEW_BROWSER_EXTENSION_CONNECTION";
diff --git a/frontend/src/utils/directories.js b/frontend/src/utils/directories.js
new file mode 100644
index 0000000000000000000000000000000000000000..5a65b5336794fc62ffbcfc4e0f7458c802bc79c2
--- /dev/null
+++ b/frontend/src/utils/directories.js
@@ -0,0 +1,33 @@
+export function formatDate(dateString) {
+ const date = isNaN(new Date(dateString).getTime())
+ ? new Date()
+ : new Date(dateString);
+ const options = { year: "numeric", month: "short", day: "numeric" };
+ const formattedDate = date.toLocaleDateString("en-US", options);
+ return formattedDate;
+}
+
+export function getFileExtension(path) {
+ return path?.split(".")?.slice(-1)?.[0] || "file";
+}
+
+export function middleTruncate(str, n) {
+ const fileExtensionPattern = /([^.]*)$/;
+ const extensionMatch = str.includes(".") && str.match(fileExtensionPattern);
+
+ if (str.length <= n) return str;
+
+ if (extensionMatch && extensionMatch[1]) {
+ const extension = extensionMatch[1];
+ const nameWithoutExtension = str.replace(fileExtensionPattern, "");
+ const truncationPoint = Math.max(0, n - extension.length - 4);
+ const truncatedName =
+ nameWithoutExtension.substr(0, truncationPoint) +
+ "..." +
+ nameWithoutExtension.slice(-4);
+
+ return truncatedName + extension;
+ } else {
+ return str.length > n ? str.substr(0, n - 8) + "..." + str.slice(-4) : str;
+ }
+}
diff --git a/frontend/src/utils/keyboardShortcuts.js b/frontend/src/utils/keyboardShortcuts.js
new file mode 100644
index 0000000000000000000000000000000000000000..2172b7f1410a67814b3db44c0fd1db7c134ed085
--- /dev/null
+++ b/frontend/src/utils/keyboardShortcuts.js
@@ -0,0 +1,135 @@
+import paths from "./paths";
+import { useEffect } from "react";
+import { userFromStorage } from "./request";
+import { TOGGLE_LLM_SELECTOR_EVENT } from "@/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/action";
+
+export const KEYBOARD_SHORTCUTS_HELP_EVENT = "keyboard-shortcuts-help";
+export const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
+export const SHORTCUTS = {
+ "⌘ + ,": {
+ translationKey: "settings",
+ action: () => {
+ window.location.href = paths.settings.interface();
+ },
+ },
+ "⌘ + H": {
+ translationKey: "home",
+ action: () => {
+ window.location.href = paths.home();
+ },
+ },
+ "⌘ + I": {
+ translationKey: "workspaces",
+ action: () => {
+ window.location.href = paths.settings.workspaces();
+ },
+ },
+ "⌘ + K": {
+ translationKey: "apiKeys",
+ action: () => {
+ window.location.href = paths.settings.apiKeys();
+ },
+ },
+ "⌘ + L": {
+ translationKey: "llmPreferences",
+ action: () => {
+ window.location.href = paths.settings.llmPreference();
+ },
+ },
+ "⌘ + Shift + C": {
+ translationKey: "chatSettings",
+ action: () => {
+ window.location.href = paths.settings.chat();
+ },
+ },
+ "⌘ + Shift + ?": {
+ translationKey: "help",
+ action: () => {
+ window.dispatchEvent(
+ new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, {
+ detail: { show: true },
+ })
+ );
+ },
+ },
+ F1: {
+ translationKey: "help",
+ action: () => {
+ window.dispatchEvent(
+ new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, {
+ detail: { show: true },
+ })
+ );
+ },
+ },
+ "⌘ + Shift + L": {
+ translationKey: "showLLMSelector",
+ action: () => {
+ window.dispatchEvent(new Event(TOGGLE_LLM_SELECTOR_EVENT));
+ },
+ },
+};
+
+const LISTENERS = {};
+const modifier = isMac ? "meta" : "ctrl";
+for (const key in SHORTCUTS) {
+ const listenerKey = key
+ .replace("⌘", modifier)
+ .replaceAll(" ", "")
+ .toLowerCase();
+ LISTENERS[listenerKey] = SHORTCUTS[key].action;
+}
+
+// Convert keyboard event to shortcut key
+function getShortcutKey(event) {
+ let key = "";
+ if (event.metaKey || event.ctrlKey) key += modifier + "+";
+ if (event.shiftKey) key += "shift+";
+ if (event.altKey) key += "alt+";
+
+ // Handle special keys
+ if (event.key === ",") key += ",";
+ // Handle question mark or slash for help shortcut
+ else if (event.key === "?" || event.key === "/") key += "?";
+ else if (event.key === "Control")
+ return ""; // Ignore Control key by itself
+ else if (event.key === "Shift")
+ return ""; // Ignore Shift key by itself
+ else key += event.key.toLowerCase();
+ return key;
+}
+
+// Initialize keyboard shortcuts
+export function initKeyboardShortcuts() {
+ function handleKeyDown(event) {
+ const shortcutKey = getShortcutKey(event);
+ if (!shortcutKey) return;
+
+ const action = LISTENERS[shortcutKey];
+ if (action) {
+ event.preventDefault();
+ action();
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+}
+
+function useKeyboardShortcuts() {
+ useEffect(() => {
+ // If there is a user and the user is not an admin do not register the event listener
+ // since some of the shortcuts are only available in multi-user mode as admin
+ const user = userFromStorage();
+ if (!!user && user?.role !== "admin") return;
+ const cleanup = initKeyboardShortcuts();
+
+ return () => cleanup();
+ }, []);
+ return;
+}
+
+export function KeyboardShortcutWrapper({ children }) {
+ useKeyboardShortcuts();
+ return children;
+}
diff --git a/frontend/src/utils/numbers.js b/frontend/src/utils/numbers.js
new file mode 100644
index 0000000000000000000000000000000000000000..0b4da3cbdbe4449d52bf0cb5dec4be46fc7b64df
--- /dev/null
+++ b/frontend/src/utils/numbers.js
@@ -0,0 +1,60 @@
+const Formatter = Intl.NumberFormat("en", { notation: "compact" });
+
+export function numberWithCommas(input) {
+ return input.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+}
+
+export function nFormatter(input) {
+ return Formatter.format(input);
+}
+
+export function dollarFormat(input) {
+ return new Intl.NumberFormat("en-us", {
+ style: "currency",
+ currency: "USD",
+ }).format(input);
+}
+
+export function toPercentString(input = null, decimals = 0) {
+ if (isNaN(input) || input === null) return "";
+ const percentage = Math.round(input * 100);
+ return (
+ (decimals > 0 ? percentage.toFixed(decimals) : percentage.toString()) + "%"
+ );
+}
+
+export function humanFileSize(bytes, si = false, dp = 1) {
+ const thresh = si ? 1000 : 1024;
+
+ if (Math.abs(bytes) < thresh) {
+ return bytes + " B";
+ }
+
+ const units = si
+ ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
+ : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
+ let u = -1;
+ const r = 10 ** dp;
+
+ do {
+ bytes /= thresh;
+ ++u;
+ } while (
+ Math.round(Math.abs(bytes) * r) / r >= thresh &&
+ u < units.length - 1
+ );
+
+ return bytes.toFixed(dp) + " " + units[u];
+}
+
+export function milliToHms(milli = 0) {
+ const d = parseFloat(milli) / 1_000.0;
+ var h = Math.floor(d / 3600);
+ var m = Math.floor((d % 3600) / 60);
+ var s = parseFloat((d % 3600.0) % 60);
+
+ var hDisplay = h >= 1 ? h + "h " : "";
+ var mDisplay = m >= 1 ? m + "m " : "";
+ var sDisplay = s >= 0.01 ? s.toFixed(2) + "s" : "";
+ return hDisplay + mDisplay + sDisplay;
+}
diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js
new file mode 100644
index 0000000000000000000000000000000000000000..f86cfe366a79b40f1ca83e29628ab4471128fc10
--- /dev/null
+++ b/frontend/src/utils/paths.js
@@ -0,0 +1,229 @@
+import { API_BASE } from "./constants";
+
+function applyOptions(path, options = {}) {
+ let updatedPath = path;
+ if (!options || Object.keys(options).length === 0) return updatedPath;
+
+ if (options.search) {
+ const searchParams = new URLSearchParams(options.search);
+ updatedPath += `?${searchParams.toString()}`;
+ }
+ return updatedPath;
+}
+
+export default {
+ home: () => {
+ return "/";
+ },
+ login: (noTry = false) => {
+ return `/login${noTry ? "?nt=1" : ""}`;
+ },
+ sso: {
+ login: () => {
+ return "/sso/simple";
+ },
+ },
+ onboarding: {
+ home: () => {
+ return "/onboarding";
+ },
+ survey: () => {
+ return "/onboarding/survey";
+ },
+ llmPreference: () => {
+ return "/onboarding/llm-preference";
+ },
+ embeddingPreference: () => {
+ return "/onboarding/embedding-preference";
+ },
+ vectorDatabase: () => {
+ return "/onboarding/vector-database";
+ },
+ userSetup: () => {
+ return "/onboarding/user-setup";
+ },
+ dataHandling: () => {
+ return "/onboarding/data-handling";
+ },
+ createWorkspace: () => {
+ return "/onboarding/create-workspace";
+ },
+ },
+ github: () => {
+ return "https://github.com/Mintplex-Labs/anything-llm";
+ },
+ discord: () => {
+ return "https://discord.com/invite/6UyHPeGZAC";
+ },
+ docs: () => {
+ return "https://docs.anythingllm.com";
+ },
+ chatModes: () => {
+ return "https://docs.anythingllm.com/features/chat-modes";
+ },
+ mailToMintplex: () => {
+ return "mailto:team@mintplexlabs.com";
+ },
+ hosting: () => {
+ return "https://my.mintplexlabs.com/aio-checkout?product=anythingllm";
+ },
+ workspace: {
+ chat: (slug, options = {}) => {
+ return applyOptions(`/workspace/${slug}`, options);
+ },
+ settings: {
+ generalAppearance: (slug) => {
+ return `/workspace/${slug}/settings/general-appearance`;
+ },
+ chatSettings: function (slug, options = {}) {
+ return applyOptions(
+ `/workspace/${slug}/settings/chat-settings`,
+ options
+ );
+ },
+ vectorDatabase: (slug) => {
+ return `/workspace/${slug}/settings/vector-database`;
+ },
+ members: (slug) => {
+ return `/workspace/${slug}/settings/members`;
+ },
+ agentConfig: (slug) => {
+ return `/workspace/${slug}/settings/agent-config`;
+ },
+ },
+ thread: (wsSlug, threadSlug) => {
+ return `/workspace/${wsSlug}/t/${threadSlug}`;
+ },
+ },
+ apiDocs: () => {
+ return `${API_BASE}/docs`;
+ },
+ settings: {
+ users: () => {
+ return `/settings/users`;
+ },
+ invites: () => {
+ return `/settings/invites`;
+ },
+ workspaces: () => {
+ return `/settings/workspaces`;
+ },
+ chats: () => {
+ return "/settings/workspace-chats";
+ },
+ llmPreference: () => {
+ return "/settings/llm-preference";
+ },
+ transcriptionPreference: () => {
+ return "/settings/transcription-preference";
+ },
+ audioPreference: () => {
+ return "/settings/audio-preference";
+ },
+ embedder: {
+ modelPreference: () => "/settings/embedding-preference",
+ chunkingPreference: () => "/settings/text-splitter-preference",
+ },
+ embeddingPreference: () => {
+ return "/settings/embedding-preference";
+ },
+ vectorDatabase: () => {
+ return "/settings/vector-database";
+ },
+ security: () => {
+ return "/settings/security";
+ },
+ interface: () => {
+ return "/settings/interface";
+ },
+ branding: () => {
+ return "/settings/branding";
+ },
+ agentSkills: () => {
+ return "/settings/agents";
+ },
+ chat: () => {
+ return "/settings/chat";
+ },
+ apiKeys: () => {
+ return "/settings/api-keys";
+ },
+ systemPromptVariables: () => "/settings/system-prompt-variables",
+ logs: () => {
+ return "/settings/event-logs";
+ },
+ privacy: () => {
+ return "/settings/privacy";
+ },
+ embedChatWidgets: () => {
+ return `/settings/embed-chat-widgets`;
+ },
+ browserExtension: () => {
+ return `/settings/browser-extension`;
+ },
+ experimental: () => {
+ return `/settings/beta-features`;
+ },
+ mobileConnections: () => {
+ return `/settings/mobile-connections`;
+ },
+ },
+ agents: {
+ builder: () => {
+ return `/settings/agents/builder`;
+ },
+ editAgent: (uuid) => {
+ return `/settings/agents/builder/${uuid}`;
+ },
+ },
+ communityHub: {
+ website: () => {
+ return import.meta.env.DEV
+ ? `http://localhost:5173`
+ : `https://hub.anythingllm.com`;
+ },
+ /**
+ * View more items of a given type on the community hub.
+ * @param {string} type - The type of items to view more of. Should be kebab-case.
+ * @returns {string} The path to view more items of the given type.
+ */
+ viewMoreOfType: function (type) {
+ return `${this.website()}/list/${type}`;
+ },
+ viewItem: function (type, id) {
+ return `${this.website()}/i/${type}/${id}`;
+ },
+ trending: () => {
+ return `/settings/community-hub/trending`;
+ },
+ authentication: () => {
+ return `/settings/community-hub/authentication`;
+ },
+ importItem: (importItemId) => {
+ return `/settings/community-hub/import-item${importItemId ? `?id=${importItemId}` : ""}`;
+ },
+ profile: function (username) {
+ if (username) return `${this.website()}/u/${username}`;
+ return `${this.website()}/me`;
+ },
+ noPrivateItems: () => {
+ return "https://docs.anythingllm.com/community-hub/faq#no-private-items";
+ },
+ },
+
+ // TODO: Migrate all docs.anythingllm.com links to the new docs.
+ documentation: {
+ mobileIntroduction: () => {
+ return "https://docs.anythingllm.com/mobile/overview";
+ },
+ contextWindows: () => {
+ return "https://docs.anythingllm.com/chatting-with-documents/introduction#you-exceed-the-context-window---what-now";
+ },
+ },
+
+ experimental: {
+ liveDocumentSync: {
+ manage: () => `/settings/beta-features/live-document-sync/manage`,
+ },
+ },
+};
diff --git a/frontend/src/utils/piperTTS/index.js b/frontend/src/utils/piperTTS/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..5016af79e7747fabdd910840115190b0fe1297bb
--- /dev/null
+++ b/frontend/src/utils/piperTTS/index.js
@@ -0,0 +1,138 @@
+import showToast from "../toast";
+
+export default class PiperTTSClient {
+ static _instance;
+ voiceId = "en_US-hfc_female-medium";
+ worker = null;
+
+ constructor({ voiceId } = { voiceId: null }) {
+ if (PiperTTSClient._instance) {
+ this.voiceId = voiceId !== null ? voiceId : this.voiceId;
+ return PiperTTSClient._instance;
+ }
+
+ this.voiceId = voiceId !== null ? voiceId : this.voiceId;
+ PiperTTSClient._instance = this;
+ return this;
+ }
+
+ #getWorker() {
+ if (!this.worker)
+ this.worker = new Worker(new URL("./worker.js", import.meta.url), {
+ type: "module",
+ });
+ return this.worker;
+ }
+
+ /**
+ * Get all available voices for a client
+ * @returns {Promise}
+ */
+ static async voices() {
+ const tmpWorker = new Worker(new URL("./worker.js", import.meta.url), {
+ type: "module",
+ });
+ tmpWorker.postMessage({ type: "voices" });
+ return new Promise((resolve, reject) => {
+ let timeout = null;
+ const handleMessage = (event) => {
+ if (event.data.type !== "voices") {
+ console.log("PiperTTSWorker debug event:", event.data);
+ return;
+ }
+ resolve(event.data.voices);
+ tmpWorker.removeEventListener("message", handleMessage);
+ timeout && clearTimeout(timeout);
+ tmpWorker.terminate();
+ };
+
+ timeout = setTimeout(() => {
+ reject("TTS Worker timed out.");
+ }, 30_000);
+ tmpWorker.addEventListener("message", handleMessage);
+ });
+ }
+
+ static async flush() {
+ const tmpWorker = new Worker(new URL("./worker.js", import.meta.url), {
+ type: "module",
+ });
+ tmpWorker.postMessage({ type: "flush" });
+ return new Promise((resolve, reject) => {
+ let timeout = null;
+ const handleMessage = (event) => {
+ if (event.data.type !== "flush") {
+ console.log("PiperTTSWorker debug event:", event.data);
+ return;
+ }
+ resolve(event.data.flushed);
+ tmpWorker.removeEventListener("message", handleMessage);
+ timeout && clearTimeout(timeout);
+ tmpWorker.terminate();
+ };
+
+ timeout = setTimeout(() => {
+ reject("TTS Worker timed out.");
+ }, 30_000);
+ tmpWorker.addEventListener("message", handleMessage);
+ });
+ }
+
+ /**
+ * Runs prediction via webworker so we can get an audio blob back.
+ * @returns {Promise<{blobURL: string|null, error: string|null}>} objectURL blob: type.
+ */
+ async waitForBlobResponse() {
+ return new Promise((resolve) => {
+ let timeout = null;
+ const handleMessage = (event) => {
+ if (event.data.type === "error") {
+ this.worker.removeEventListener("message", handleMessage);
+ timeout && clearTimeout(timeout);
+ return resolve({ blobURL: null, error: event.data.message });
+ }
+
+ if (event.data.type !== "result") {
+ console.log("PiperTTSWorker debug event:", event.data);
+ return;
+ }
+ resolve({
+ blobURL: URL.createObjectURL(event.data.audio),
+ error: null,
+ });
+ this.worker.removeEventListener("message", handleMessage);
+ timeout && clearTimeout(timeout);
+ };
+
+ timeout = setTimeout(() => {
+ resolve({ blobURL: null, error: "PiperTTSWorker Worker timed out." });
+ }, 30_000);
+ this.worker.addEventListener("message", handleMessage);
+ });
+ }
+
+ async getAudioBlobForText(textToSpeak, voiceId = null) {
+ const primaryWorker = this.#getWorker();
+ primaryWorker.postMessage({
+ type: "init",
+ text: String(textToSpeak),
+ voiceId: voiceId ?? this.voiceId,
+ // Don't reference WASM because in the docker image
+ // the user will be connected to internet (mostly)
+ // and it bloats the app size on the frontend or app significantly
+ // and running the docker image fully offline is not an intended use-case unlike the app.
+ });
+
+ const { blobURL, error } = await this.waitForBlobResponse();
+ if (!!error) {
+ showToast(
+ `Could not generate voice prediction. Error: ${error}`,
+ "error",
+ { clear: true }
+ );
+ return;
+ }
+
+ return blobURL;
+ }
+}
diff --git a/frontend/src/utils/piperTTS/worker.js b/frontend/src/utils/piperTTS/worker.js
new file mode 100644
index 0000000000000000000000000000000000000000..e0fa8aabbd1be388799cdcb3a6a5ec182e8c7f99
--- /dev/null
+++ b/frontend/src/utils/piperTTS/worker.js
@@ -0,0 +1,94 @@
+import * as TTS from "@mintplex-labs/piper-tts-web";
+
+/** @type {import("@mintplexlabs/piper-web-tts").TtsSession | null} */
+let PIPER_SESSION = null;
+
+/**
+ * @typedef PredictionRequest
+ * @property {('init')} type
+ * @property {string} text - the text to inference on
+ * @property {import('@mintplexlabs/piper-web-tts').VoiceId} voiceId - the voiceID key to use.
+ * @property {string|null} baseUrl - the base URL to fetch WASMs from.
+ */
+/**
+ * @typedef PredictionRequestResponse
+ * @property {('result')} type
+ * @property {Blob} audio - the text to inference on
+ */
+
+/**
+ * @typedef VoicesRequest
+ * @property {('voices')} type
+ * @property {string|null} baseUrl - the base URL to fetch WASMs from.
+ */
+/**
+ * @typedef VoicesRequestResponse
+ * @property {('voices')} type
+ * @property {[import("@mintplex-labs/piper-tts-web/dist/types")['Voice']]} voices - available voices in array
+ */
+
+/**
+ * @typedef FlushRequest
+ * @property {('flush')} type
+ */
+/**
+ * @typedef FlushRequestResponse
+ * @property {('flush')} type
+ * @property {true} flushed
+ */
+
+/**
+ * Web worker for generating client-side PiperTTS predictions
+ * @param {MessageEvent} event - The event object containing the prediction request
+ * @returns {Promise}
+ */
+async function main(event) {
+ if (event.data.type === "voices") {
+ const stored = await TTS.stored();
+ const voices = await TTS.voices();
+ voices.forEach((voice) => (voice.is_stored = stored.includes(voice.key)));
+
+ self.postMessage({ type: "voices", voices });
+ return;
+ }
+
+ if (event.data.type === "flush") {
+ await TTS.flush();
+ self.postMessage({ type: "flush", flushed: true });
+ return;
+ }
+
+ if (event.data?.type !== "init") return;
+ if (!PIPER_SESSION) {
+ PIPER_SESSION = new TTS.TtsSession({
+ voiceId: event.data.voiceId,
+ progress: (e) => self.postMessage(JSON.stringify(e)),
+ logger: (msg) => self.postMessage(msg),
+ ...(!!event.data.baseUrl
+ ? {
+ wasmPaths: {
+ onnxWasm: `${event.data.baseUrl}/piper/ort/`,
+ piperData: `${event.data.baseUrl}/piper/piper_phonemize.data`,
+ piperWasm: `${event.data.baseUrl}/piper/piper_phonemize.wasm`,
+ },
+ }
+ : {}),
+ });
+ }
+
+ if (event.data.voiceId && PIPER_SESSION.voiceId !== event.data.voiceId)
+ PIPER_SESSION.voiceId = event.data.voiceId;
+
+ PIPER_SESSION.predict(event.data.text)
+ .then((res) => {
+ if (res instanceof Blob) {
+ self.postMessage({ type: "result", audio: res });
+ return;
+ }
+ })
+ .catch((error) => {
+ self.postMessage({ type: "error", message: error.message, error }); // Will be an error.
+ });
+}
+
+self.addEventListener("message", main);
diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js
new file mode 100644
index 0000000000000000000000000000000000000000..a568fb8e1f1b862b12346fc3d903d326ff5debbe
--- /dev/null
+++ b/frontend/src/utils/request.js
@@ -0,0 +1,26 @@
+import { AUTH_TOKEN, AUTH_USER } from "./constants";
+
+// Sets up the base headers for all authenticated requests so that we are able to prevent
+// basic spoofing since a valid token is required and that cannot be spoofed
+export function userFromStorage() {
+ try {
+ const userString = window.localStorage.getItem(AUTH_USER);
+ if (!userString) return null;
+ return JSON.parse(userString);
+ } catch {}
+ return {};
+}
+
+export function baseHeaders(providedToken = null) {
+ const token = providedToken || window.localStorage.getItem(AUTH_TOKEN);
+ return {
+ Authorization: token ? `Bearer ${token}` : null,
+ };
+}
+
+export function safeJsonParse(jsonString, fallback = null) {
+ try {
+ return JSON.parse(jsonString);
+ } catch {}
+ return fallback;
+}
diff --git a/frontend/src/utils/session.js b/frontend/src/utils/session.js
new file mode 100644
index 0000000000000000000000000000000000000000..27228e482b4560698f2b1dd756f9c8a00540c85d
--- /dev/null
+++ b/frontend/src/utils/session.js
@@ -0,0 +1,15 @@
+import { API_BASE } from "./constants";
+import { baseHeaders } from "./request";
+
+// Checks current localstorage and validates the session based on that.
+export default async function validateSessionTokenForUser() {
+ const isValidSession = await fetch(`${API_BASE}/system/check-token`, {
+ method: "GET",
+ cache: "default",
+ headers: baseHeaders(),
+ })
+ .then((res) => res.status === 200)
+ .catch(() => false);
+
+ return isValidSession;
+}
diff --git a/frontend/src/utils/toast.js b/frontend/src/utils/toast.js
new file mode 100644
index 0000000000000000000000000000000000000000..7647c27b21b0c1722c4bbd7f1f3784225ceb25fc
--- /dev/null
+++ b/frontend/src/utils/toast.js
@@ -0,0 +1,39 @@
+import { toast } from "react-toastify";
+
+// Additional Configs (opts)
+// You can also pass valid ReactToast params to override the defaults.
+// clear: false, // Will dismiss all visible toasts before rendering next toast
+const showToast = (message, type = "default", opts = {}) => {
+ const theme = localStorage?.getItem("theme") || "default";
+ const options = {
+ position: "bottom-center",
+ autoClose: 5000,
+ hideProgressBar: false,
+ closeOnClick: true,
+ pauseOnHover: true,
+ draggable: true,
+ theme: theme === "default" ? "dark" : "light",
+ ...opts,
+ };
+
+ if (opts?.clear === true) toast.dismiss();
+
+ switch (type) {
+ case "success":
+ toast.success(message, options);
+ break;
+ case "error":
+ toast.error(message, options);
+ break;
+ case "info":
+ toast.info(message, options);
+ break;
+ case "warning":
+ toast.warn(message, options);
+ break;
+ default:
+ toast(message, options);
+ }
+};
+
+export default showToast;
diff --git a/frontend/src/utils/types.js b/frontend/src/utils/types.js
new file mode 100644
index 0000000000000000000000000000000000000000..b63d49f6a31a446661659a43a53f8e2b8e7a1054
--- /dev/null
+++ b/frontend/src/utils/types.js
@@ -0,0 +1,19 @@
+export function castToType(key, value) {
+ const definitions = {
+ openAiTemp: {
+ cast: (value) => Number(value),
+ },
+ openAiHistory: {
+ cast: (value) => Number(value),
+ },
+ similarityThreshold: {
+ cast: (value) => parseFloat(value),
+ },
+ topN: {
+ cast: (value) => Number(value),
+ },
+ };
+
+ if (!definitions.hasOwnProperty(key)) return value;
+ return definitions[key].cast(value);
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..3f07e74b059265db6e309aa04b65423f6a879099
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,294 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: "class",
+ content: {
+ relative: true,
+ files: [
+ "./src/components/**/*.{js,jsx}",
+ "./src/hooks/**/*.js",
+ "./src/models/**/*.js",
+ "./src/pages/**/*.{js,jsx}",
+ "./src/utils/**/*.js",
+ "./src/*.jsx",
+ "./index.html",
+ "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}"
+ ]
+ },
+ theme: {
+ extend: {
+ rotate: {
+ "270": "270deg",
+ "360": "360deg"
+ },
+ colors: {
+ "black-900": "#141414",
+ accent: "#3D4147",
+ "sidebar-button": "#31353A",
+ sidebar: "#25272C",
+ "historical-msg-system": "rgba(255, 255, 255, 0.05);",
+ "historical-msg-user": "#2C2F35",
+ outline: "#4E5153",
+ "primary-button": "var(--theme-button-primary)",
+ "cta-button": "var(--theme-button-cta)",
+ secondary: "#2C2F36",
+ "dark-input": "#18181B",
+ "mobile-onboarding": "#2C2F35",
+ "dark-highlight": "#1C1E21",
+ "dark-text": "#222628",
+ description: "#D2D5DB",
+ "x-button": "#9CA3AF",
+ royalblue: "#065986",
+ purple: "#4A1FB8",
+ magenta: "#9E165F",
+ danger: "#F04438",
+ error: "#B42318",
+ warn: "#854708",
+ success: "#05603A",
+ darker: "#F4F4F4",
+ teal: "#0BA5EC",
+
+ // Generic theme colors
+ theme: {
+ bg: {
+ primary: 'var(--theme-bg-primary)',
+ secondary: 'var(--theme-bg-secondary)',
+ sidebar: 'var(--theme-bg-sidebar)',
+ container: 'var(--theme-bg-container)',
+ chat: 'var(--theme-bg-chat)',
+ "chat-input": 'var(--theme-bg-chat-input)',
+ "popup-menu": 'var(--theme-popup-menu-bg)',
+ },
+ text: {
+ primary: 'var(--theme-text-primary)',
+ secondary: 'var(--theme-text-secondary)',
+ placeholder: 'var(--theme-placeholder)',
+ },
+ sidebar: {
+ item: {
+ default: 'var(--theme-sidebar-item-default)',
+ selected: 'var(--theme-sidebar-item-selected)',
+ hover: 'var(--theme-sidebar-item-hover)',
+ },
+ subitem: {
+ default: 'var(--theme-sidebar-subitem-default)',
+ selected: 'var(--theme-sidebar-subitem-selected)',
+ hover: 'var(--theme-sidebar-subitem-hover)',
+ },
+ footer: {
+ icon: 'var(--theme-sidebar-footer-icon)',
+ 'icon-hover': 'var(--theme-sidebar-footer-icon-hover)',
+ },
+ border: 'var(--theme-sidebar-border)',
+ },
+ "chat-input": {
+ border: 'var(--theme-chat-input-border)',
+ },
+ "action-menu": {
+ bg: 'var(--theme-action-menu-bg)',
+ "item-hover": 'var(--theme-action-menu-item-hover)',
+ },
+ settings: {
+ input: {
+ bg: 'var(--theme-settings-input-bg)',
+ active: 'var(--theme-settings-input-active)',
+ placeholder: 'var(--theme-settings-input-placeholder)',
+ text: 'var(--theme-settings-input-text)',
+ }
+ },
+ modal: {
+ border: 'var(--theme-modal-border)',
+ },
+ "file-picker": {
+ hover: 'var(--theme-file-picker-hover)',
+ },
+ attachment: {
+ bg: 'var(--theme-attachment-bg)',
+ 'error-bg': 'var(--theme-attachment-error-bg)',
+ 'success-bg': 'var(--theme-attachment-success-bg)',
+ text: 'var(--theme-attachment-text)',
+ 'text-secondary': 'var(--theme-attachment-text-secondary)',
+ 'icon': 'var(--theme-attachment-icon)',
+ 'icon-spinner': 'var(--theme-attachment-icon-spinner)',
+ 'icon-spinner-bg': 'var(--theme-attachment-icon-spinner-bg)',
+ },
+ home: {
+ text: 'var(--theme-home-text)',
+ "text-secondary": 'var(--theme-home-text-secondary)',
+ "bg-card": 'var(--theme-home-bg-card)',
+ "bg-button": 'var(--theme-home-bg-button)',
+ border: 'var(--theme-home-border)',
+ "button-primary": 'var(--theme-home-button-primary)',
+ "button-primary-hover": 'var(--theme-home-button-primary-hover)',
+ "button-secondary": 'var(--theme-home-button-secondary)',
+ "button-secondary-hover": 'var(--theme-home-button-secondary-hover)',
+ "button-secondary-text": 'var(--theme-home-button-secondary-text)',
+ "button-secondary-hover-text": 'var(--theme-home-button-secondary-hover-text)',
+ "button-secondary-border": 'var(--theme-home-button-secondary-border)',
+ "button-secondary-border-hover": 'var(--theme-home-button-secondary-border-hover)',
+ "update-card-bg": 'var(--theme-home-update-card-bg)',
+ "update-card-hover": 'var(--theme-home-update-card-hover)',
+ "update-source": 'var(--theme-home-update-source)',
+ },
+ checklist: {
+ "item-bg": 'var(--theme-checklist-item-bg)',
+ "item-bg-hover": 'var(--theme-checklist-item-bg-hover)',
+ "item-text": 'var(--theme-checklist-item-text)',
+ "item-completed-bg": 'var(--theme-checklist-item-completed-bg)',
+ "item-completed-text": 'var(--theme-checklist-item-completed-text)',
+ "item-hover": 'var(--theme-checklist-item-hover)',
+ "checkbox-border": 'var(--theme-checklist-checkbox-border)',
+ "checkbox-fill": 'var(--theme-checklist-checkbox-fill)',
+ "checkbox-text": 'var(--theme-checklist-checkbox-text)',
+ "button-border": 'var(--theme-checklist-button-border)',
+ "button-text": 'var(--theme-checklist-button-text)',
+ "button-hover-bg": 'var(--theme-checklist-button-hover-bg)',
+ "button-hover-border": 'var(--theme-checklist-button-hover-border)',
+ },
+ button: {
+ text: 'var(--theme-button-text)',
+ 'code-hover-text': 'var(--theme-button-code-hover-text)',
+ 'code-hover-bg': 'var(--theme-button-code-hover-bg)',
+ 'disable-hover-text': 'var(--theme-button-disable-hover-text)',
+ 'disable-hover-bg': 'var(--theme-button-disable-hover-bg)',
+ 'delete-hover-text': 'var(--theme-button-delete-hover-text)',
+ 'delete-hover-bg': 'var(--theme-button-delete-hover-bg)',
+ },
+ },
+ },
+ backgroundImage: {
+ "preference-gradient":
+ "linear-gradient(180deg, #5A5C63 0%, rgba(90, 92, 99, 0.28) 100%);",
+ "chat-msg-user-gradient":
+ "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%);",
+ "selected-preference-gradient":
+ "linear-gradient(180deg, #313236 0%, rgba(63.40, 64.90, 70.13, 0) 100%);",
+ "main-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)",
+ "modal-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)",
+ "sidebar-gradient": "linear-gradient(90deg, #5B616A 0%, #3F434B 100%)",
+ "login-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)",
+ "menu-item-gradient":
+ "linear-gradient(90deg, #3D4147 0%, #2C2F35 100%)",
+ "menu-item-selected-gradient":
+ "linear-gradient(90deg, #5B616A 0%, #3F434B 100%)",
+ "workspace-item-gradient":
+ "linear-gradient(90deg, #3D4147 0%, #2C2F35 100%)",
+ "workspace-item-selected-gradient":
+ "linear-gradient(90deg, #5B616A 0%, #3F434B 100%)",
+ "switch-selected": "linear-gradient(146deg, #5B616A 0%, #3F434B 100%)"
+ },
+ fontFamily: {
+ sans: [
+ "plus-jakarta-sans",
+ "ui-sans-serif",
+ "system-ui",
+ "-apple-system",
+ "BlinkMacSystemFont",
+ '"Segoe UI"',
+ "Roboto",
+ '"Helvetica Neue"',
+ "Arial",
+ '"Noto Sans"',
+ "sans-serif",
+ '"Apple Color Emoji"',
+ '"Segoe UI Emoji"',
+ '"Segoe UI Symbol"',
+ '"Noto Color Emoji"'
+ ]
+ },
+ animation: {
+ sweep: "sweep 0.5s ease-in-out",
+ "pulse-glow": "pulse-glow 1.5s infinite",
+ 'fade-in': 'fade-in 0.3s ease-out',
+ 'slide-up': 'slide-up 0.4s ease-out forwards',
+ 'bounce-subtle': 'bounce-subtle 2s ease-in-out infinite'
+ },
+ keyframes: {
+ sweep: {
+ "0%": { transform: "scaleX(0)", transformOrigin: "bottom left" },
+ "100%": { transform: "scaleX(1)", transformOrigin: "bottom left" }
+ },
+ fadeIn: {
+ "0%": { opacity: 0 },
+ "100%": { opacity: 1 }
+ },
+ fadeOut: {
+ "0%": { opacity: 1 },
+ "100%": { opacity: 0 }
+ },
+ "pulse-glow": {
+ "0%": {
+ opacity: 1,
+ transform: "scale(1)",
+ boxShadow: "0 0 0 rgba(255, 255, 255, 0.0)",
+ backgroundColor: "rgba(255, 255, 255, 0.0)"
+ },
+ "50%": {
+ opacity: 1,
+ transform: "scale(1.1)",
+ boxShadow: "0 0 15px rgba(255, 255, 255, 0.2)",
+ backgroundColor: "rgba(255, 255, 255, 0.1)"
+ },
+ "100%": {
+ opacity: 1,
+ transform: "scale(1)",
+ boxShadow: "0 0 0 rgba(255, 255, 255, 0.0)",
+ backgroundColor: "rgba(255, 255, 255, 0.0)"
+ }
+ },
+ 'fade-in': {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' }
+ },
+ 'slide-up': {
+ '0%': { transform: 'translateY(10px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' }
+ },
+ 'bounce-subtle': {
+ '0%, 100%': { transform: 'translateY(0)' },
+ '50%': { transform: 'translateY(-2px)' }
+ }
+ }
+ }
+ },
+ variants: {
+ extend: {
+ backgroundColor: ['light'],
+ textColor: ['light'],
+ }
+ },
+ // Required for rechart styles to show since they can be rendered dynamically and will be tree-shaken if not safe-listed.
+ safelist: [
+ {
+ pattern:
+ /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
+ variants: ["hover", "ui-selected"]
+ },
+ {
+ pattern:
+ /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
+ variants: ["hover", "ui-selected"]
+ },
+ {
+ pattern:
+ /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
+ variants: ["hover", "ui-selected"]
+ },
+ {
+ pattern:
+ /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/
+ },
+ {
+ pattern:
+ /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/
+ },
+ {
+ pattern:
+ /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/
+ }
+ ],
+ plugins: [
+ function ({ addVariant }) {
+ addVariant('light', '.light &') // Add the `light:` variant
+ },
+ ]
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..73b295be2ba8cbdc1ea39847d1424feed9ad537f
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,87 @@
+import { defineConfig } from "vite"
+import { fileURLToPath, URL } from "url"
+import postcss from "./postcss.config.js"
+import react from "@vitejs/plugin-react"
+import dns from "dns"
+import { visualizer } from "rollup-plugin-visualizer"
+
+dns.setDefaultResultOrder("verbatim")
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ assetsInclude: [
+ './public/piper/ort-wasm-simd-threaded.wasm',
+ './public/piper/piper_phonemize.wasm',
+ './public/piper/piper_phonemize.data',
+ ],
+ worker: {
+ format: 'es'
+ },
+ server: {
+ port: 3000,
+ host: "localhost"
+ },
+ define: {
+ "process.env": process.env
+ },
+ css: {
+ postcss
+ },
+ plugins: [
+ react(),
+ visualizer({
+ template: "treemap", // or sunburst
+ open: false,
+ gzipSize: true,
+ brotliSize: true,
+ filename: "bundleinspector.html" // will be saved in project's root
+ })
+ ],
+ resolve: {
+ alias: [
+ {
+ find: "@",
+ replacement: fileURLToPath(new URL("./src", import.meta.url))
+ },
+ {
+ process: "process/browser",
+ stream: "stream-browserify",
+ zlib: "browserify-zlib",
+ util: "util",
+ find: /^~.+/,
+ replacement: (val) => {
+ return val.replace(/^~/, "")
+ }
+ }
+ ]
+ },
+ build: {
+ rollupOptions: {
+ output: {
+ // These settings ensure the primary JS and CSS file references are always index.{js,css}
+ // so we can SSR the index.html as text response from server/index.js without breaking references each build.
+ entryFileNames: 'index.js',
+ assetFileNames: (assetInfo) => {
+ if (assetInfo.name === 'index.css') return `index.css`;
+ return assetInfo.name;
+ },
+ },
+ external: [
+ // Reduces transformation time by 50% and we don't even use this variant, so we can ignore.
+ /@phosphor-icons\/react\/dist\/ssr/,
+ ]
+ },
+ commonjsOptions: {
+ transformMixedEsModules: true
+ }
+ },
+ optimizeDeps: {
+ include: ["@mintplex-labs/piper-tts-web"],
+ esbuildOptions: {
+ define: {
+ global: "globalThis"
+ },
+ plugins: []
+ }
+ }
+})
diff --git a/images/LLMproviders/localai-embedding.png b/images/LLMproviders/localai-embedding.png
new file mode 100644
index 0000000000000000000000000000000000000000..d7f28071661b356fd4a9b89b95312e0739d415bf
--- /dev/null
+++ b/images/LLMproviders/localai-embedding.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1b111f45f07bdc5a720cd0a4585e9286a185dc20eea45fad1bfc8093d3acccf0
+size 352502
diff --git a/images/LLMproviders/localai-setup.png b/images/LLMproviders/localai-setup.png
new file mode 100644
index 0000000000000000000000000000000000000000..501536fdb1fb89121d6a710bce655051efcc0754
--- /dev/null
+++ b/images/LLMproviders/localai-setup.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12e7ffb073cca6f6d4f248b1dba1cd56329390ee6c568bad28663163b4d75739
+size 462780
diff --git a/images/choices.png b/images/choices.png
new file mode 100644
index 0000000000000000000000000000000000000000..902590bb545806e4675b3b6643ea9219c575e800
--- /dev/null
+++ b/images/choices.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6011d9109c8c3cc5cf5f33e3445d1e390a34e1dbd359eb1900cd608931ebb450
+size 155547
diff --git a/images/deployBtns/aws.png b/images/deployBtns/aws.png
new file mode 100644
index 0000000000000000000000000000000000000000..67f0071ed9312c31f7e9784b6783c6e93eacc022
--- /dev/null
+++ b/images/deployBtns/aws.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fcecb5cab89a86bbe18df29d38b0b91a8e69aaef0f5f21c20f58c00068dea24b
+size 3560
diff --git a/images/deployBtns/docker.png b/images/deployBtns/docker.png
new file mode 100644
index 0000000000000000000000000000000000000000..ecc6ce8fb772e8e534e4cc59ea99e2608a69c750
--- /dev/null
+++ b/images/deployBtns/docker.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cecbea22b0511a469a350a76fc0689328acc7982f3ff8bee06aa69079bb86077
+size 2746
diff --git a/images/gcp-project-bar.png b/images/gcp-project-bar.png
new file mode 100644
index 0000000000000000000000000000000000000000..20743793f31d121ecaeac71ea37d962151cf867c
--- /dev/null
+++ b/images/gcp-project-bar.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f906f66026ae52847f76f6a48e013fe80b5b77b955243af8c20f4761b81fb3f2
+size 19182
diff --git a/images/promo.png b/images/promo.png
new file mode 100644
index 0000000000000000000000000000000000000000..88ae9c827927dcb53e147d0f28f3922d57b601ca
--- /dev/null
+++ b/images/promo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:22cd3c73ddcd374953dc0ee8523b1a736cf44b57ab5f2ab19f7d57f1f13f9595
+size 493917
diff --git a/images/screenshots/cf_outputs.png b/images/screenshots/cf_outputs.png
new file mode 100644
index 0000000000000000000000000000000000000000..bb93d8b4dc903a85351a02b407be267aaea93dd5
--- /dev/null
+++ b/images/screenshots/cf_outputs.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:746cb9559e344b4ae55a47f505fd1efbafcbdfdc18c709825101a7770960e578
+size 105911
diff --git a/images/screenshots/create_stack.png b/images/screenshots/create_stack.png
new file mode 100644
index 0000000000000000000000000000000000000000..4f5dba133942f5477adc2d529cc8494775ed56e3
--- /dev/null
+++ b/images/screenshots/create_stack.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a69c16cdf8a60e6fbf90be39e12e8ba4bf0bb8348d380c57b456c274821e6c13
+size 27917
diff --git a/images/screenshots/upload.png b/images/screenshots/upload.png
new file mode 100644
index 0000000000000000000000000000000000000000..0f300924ddb958c6554d22acb8bbacde4c3829d8
--- /dev/null
+++ b/images/screenshots/upload.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:aabb65516366aeff2218549a14983787bb4662f6fa864c8cef67ee927980ec45
+size 250271
diff --git a/images/wordmark.png b/images/wordmark.png
new file mode 100644
index 0000000000000000000000000000000000000000..a79e0b563d301dabef0e9800c0b187eeea6a23a8
--- /dev/null
+++ b/images/wordmark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b68ea8d9ef7815ffb3aa48544f491adbff2ff12315b3d3009c9138b911fadf03
+size 8884
diff --git a/images/youtube.png b/images/youtube.png
new file mode 100644
index 0000000000000000000000000000000000000000..c8197faaec99e01c1262eaf673a63a9c66c849ca
--- /dev/null
+++ b/images/youtube.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:97ae300f933ba253b238e407bb044287593c96f4eb8099134b82c46f99865ace
+size 261815
diff --git a/locales/README.fa-IR.md b/locales/README.fa-IR.md
new file mode 100644
index 0000000000000000000000000000000000000000..c28abf648aee14b06796c4c41d5012062377a068
--- /dev/null
+++ b/locales/README.fa-IR.md
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+
+
+
+
+
+ AnythingLLM: اپلیکیشن همهکاره هوش مصنوعی که دنبالش بودید.
+ با اسناد خود چت کنید، از عاملهای هوش مصنوعی استفاده کنید، با قابلیت پیکربندی بالا، چند کاربره، و بدون نیاز به تنظیمات پیچیده.
+
+
+
+
+
+ |
+
+
+ |
+
+ Docs
+ |
+
+ Hosted Instance
+
+
+
+
+ English · 简体中文 · 日本語 · فارسی
+
+
+
+👈 AnythingLLM برای دسکتاپ (مک، ویندوز و لینوکس)! دانلود کنید
+
+
+
+یک اپلیکیشن کامل که به شما امکان میدهد هر سند، منبع یا محتوایی را به زمینهای تبدیل کنید که هر LLM میتواند در حین گفتگو به عنوان مرجع از آن استفاده کند. این برنامه به شما اجازه میدهد LLM یا پایگاه داده برداری مورد نظر خود را انتخاب کنید و همچنین از مدیریت چند کاربره و مجوزها پشتیبانی میکند.
+
+
+
+
+
+دموی ویدیویی را تماشا کنید!
+
+[](https://youtu.be/f95rGD9trL0)
+
+
+
+
+### نمای کلی محصول
+
+AnythingLLM یک اپلیکیشن کامل است که در آن میتوانید از LLMهای تجاری آماده یا LLMهای متنباز محبوب و راهحلهای vectorDB برای ساخت یک ChatGPT خصوصی بدون محدودیت استفاده کنید که میتوانید آن را به صورت محلی اجرا کنید یا از راه دور میزبانی کنید و با هر سندی که به آن ارائه میدهید، هوشمندانه گفتگو کنید.
+
+AnythingLLM اسناد شما را به اشیایی به نام `workspaces` تقسیم میکند. یک Workspace مانند یک رشته عمل میکند، اما با اضافه شدن کانتینرسازی اسناد شما. Workspaceها میتوانند اسناد را به اشتراک بگذارند، اما با یکدیگر ارتباط برقرار نمیکنند تا بتوانید زمینه هر workspace را تمیز نگه دارید.
+
+
+
+## ویژگیهای جذاب AnythingLLM
+
+- 🆕 [**عاملهای هوش مصنوعی سفارشی**](https://docs.anythingllm.com/agent/custom/introduction)
+- 🖼️ **پشتیبانی از چند مدل (هم LLMهای متنباز و هم تجاری!)**
+- 👤 پشتیبانی از چند کاربر و سیستم مجوزها _فقط در نسخه Docker_
+- 🦾 عاملها در فضای کاری شما (مرور وب، اجرای کد و غیره)
+- 💬 [ویجت چت قابل جاسازی سفارشی برای وبسایت شما](../embed/README.md) _فقط در نسخه Docker_
+- 📖 پشتیبانی از انواع مختلف سند (PDF، TXT، DOCX و غیره)
+- رابط کاربری ساده چت با قابلیت کشیدن و رها کردن و استنادهای واضح
+- ۱۰۰٪ آماده استقرار در فضای ابری
+- سازگار با تمام [ارائهدهندگان محبوب LLM متنباز و تجاری](#supported-llms-embedder-models-speech-models-and-vector-databases)
+- دارای اقدامات داخلی صرفهجویی در هزینه و زمان برای مدیریت اسناد بسیار بزرگ در مقایسه با سایر رابطهای کاربری چت
+- API کامل توسعهدهنده برای یکپارچهسازیهای سفارشی!
+- و موارد بیشتر... نصب کنید و کشف کنید!
+
+### LLMها، مدلهای Embedder، مدلهای گفتاری و پایگاههای داده برداری پشتیبانی شده
+
+**مدلهای زبانی بزرگ (LLMs):**
+
+- [Any open-source llama.cpp compatible model](/server/storage/models/README.md#text-generation-llm-selection)
+- [OpenAI](https://openai.com)
+- [OpenAI (Generic)](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [AWS Bedrock](https://aws.amazon.com/bedrock/)
+- [Anthropic](https://www.anthropic.com/)
+- [NVIDIA NIM (chat models)](https://build.nvidia.com/explore/discover)
+- [Google Gemini Pro](https://ai.google.dev/)
+- [Hugging Face (chat models)](https://huggingface.co/)
+- [Ollama (chat models)](https://ollama.ai/)
+- [LM Studio (all models)](https://lmstudio.ai)
+- [LocalAi (all models)](https://localai.io/)
+- [Together AI (chat models)](https://www.together.ai/)
+- [Fireworks AI (chat models)](https://fireworks.ai/)
+- [Perplexity (chat models)](https://www.perplexity.ai/)
+- [OpenRouter (chat models)](https://openrouter.ai/)
+- [DeepSeek (chat models)](https://deepseek.com/)
+- [Mistral](https://mistral.ai/)
+- [Groq](https://groq.com/)
+- [Cohere](https://cohere.com/)
+- [KoboldCPP](https://github.com/LostRuins/koboldcpp)
+- [LiteLLM](https://github.com/BerriAI/litellm)
+- [Text Generation Web UI](https://github.com/oobabooga/text-generation-webui)
+- [Apipie](https://apipie.ai/)
+- [xAI](https://x.ai/)
+- [Novita AI (chat models)](https://novita.ai/model-api/product/llm-api?utm_source=github_anything-llm&utm_medium=github_readme&utm_campaign=link)
+- [PPIO](https://ppinfra.com?utm_source=github_anything-llm)
+
+
+
+**مدلهای Embedder:**
+
+- [AnythingLLM Native Embedder](/server/storage/models/README.md) (پیشفرض)
+- [OpenAI](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [LocalAi (همه)](https://localai.io/)
+- [Ollama (همه)](https://ollama.ai/)
+- [LM Studio (همه)](https://lmstudio.ai)
+- [Cohere](https://cohere.com/)
+
+**مدلهای رونویسی صوتی:**
+
+- [AnythingLLM Built-in](https://github.com/Mintplex-Labs/anything-llm/tree/master/server/storage/models#audiovideo-transcription) (پیشفرض)
+- [OpenAI](https://openai.com/)
+
+**پشتیبانی TTS (تبدیل متن به گفتار):**
+
+- امکانات داخلی مرورگر (پیشفرض)
+- [PiperTTSLocal - اجرا در مرورگر](https://github.com/rhasspy/piper)
+- [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech/voice-options)
+- [ElevenLabs](https://elevenlabs.io/)
+- هر سرویس TTS سازگار با OpenAI
+
+**پشتیبانی STT (تبدیل گفتار به متن):**
+
+- امکانات داخلی مرورگر (پیشفرض)
+
+**پایگاههای داده برداری:**
+
+- [LanceDB](https://github.com/lancedb/lancedb) (پیشفرض)
+- [PGVector](https://github.com/pgvector/pgvector)
+- [Astra DB](https://www.datastax.com/products/datastax-astra)
+- [Pinecone](https://pinecone.io)
+- [Chroma](https://trychroma.com)
+- [Weaviate](https://weaviate.io)
+- [Qdrant](https://qdrant.tech)
+- [Milvus](https://milvus.io)
+- [Zilliz](https://zilliz.com)
+
+### نمای کلی فنی
+
+این مخزن شامل سه بخش اصلی است:
+
+- `frontend`: یک رابط کاربری viteJS + React که میتوانید برای ایجاد و مدیریت آسان تمام محتوای قابل استفاده توسط LLM اجرا کنید.
+- `server`: یک سرور NodeJS express برای مدیریت تمام تعاملات و انجام مدیریت vectorDB و تعاملات LLM.
+- `collector`: سرور NodeJS express که اسناد را از رابط کاربری پردازش و تجزیه میکند.
+- `docker`: دستورالعملهای Docker و فرآیند ساخت + اطلاعات برای ساخت از منبع.
+- `embed`: زیرماژول برای تولید و ایجاد [ویجت قابل جاسازی وب](https://github.com/Mintplex-Labs/anythingllm-embed).
+- `browser-extension`: زیرماژول برای [افزونه مرورگر کروم](https://github.com/Mintplex-Labs/anythingllm-extension).
+
+
+
+## 🛳 میزبانی شخصی
+
+
+
+Mintplex Labs و جامعه کاربران، روشها، اسکریپتها و قالبهای متعددی را برای اجرای AnythingLLM به صورت محلی نگهداری میکنند. برای مطالعه نحوه استقرار در محیط مورد نظر خود یا استقرار خودکار، به جدول زیر مراجعه کنید.
+
+
+| Docker | AWS | GCP | Digital Ocean | Render.com |
+|----------------------------------------|----|-----|---------------|------------|
+| [![Deploy on Docker][docker-btn]][docker-deploy] | [![Deploy on AWS][aws-btn]][aws-deploy] | [![Deploy on GCP][gcp-btn]][gcp-deploy] | [![Deploy on DigitalOcean][do-btn]][do-deploy] | [![Deploy on Render.com][render-btn]][render-deploy] |
+
+| Railway | RepoCloud | Elestio |
+| --- | --- | --- |
+| [![Deploy on Railway][railway-btn]][railway-deploy] | [![Deploy on RepoCloud][repocloud-btn]][repocloud-deploy] | [![Deploy on Elestio][elestio-btn]][elestio-deploy] |
+
+
+
+[یا راهاندازی نمونه تولیدی AnythingLLM بدون Docker →](../BARE_METAL.md)
+
+## راهاندازی برای توسعه
+
+- `yarn setup` برای پر کردن فایلهای `.env` مورد نیاز در هر بخش از برنامه (از ریشه مخزن).
+ - قبل از ادامه، آنها را پر کنید. اطمینان حاصل کنید که `server/.env.development` پر شده است، در غیر این صورت همه چیز درست کار نخواهد کرد.
+- `yarn dev:server` برای راهاندازی سرور به صورت محلی (از ریشه مخزن).
+- `yarn dev:frontend` برای راهاندازی فرانتاند به صورت محلی (از ریشه مخزن).
+- `yarn dev:collector` برای اجرای جمعکننده اسناد (از ریشه مخزن).
+
+[درباره اسناد بیشتر بدانید](../server/storage/documents/DOCUMENTS.md)
+
+[درباره کشکردن بردار بیشتر بدانید](../server/storage/vector-cache/VECTOR_CACHE.md)
+
+## تلهمتری و حریم خصوصی
+
+AnythingLLM توسط Mintplex Labs Inc دارای ویژگی تلهمتری است که اطلاعات استفاده ناشناس را جمعآوری میکند.
+
+
+اطلاعات بیشتر درباره تلهمتری و حریم خصوصی AnythingLLM
+
+### چرا؟
+
+
+ما از این اطلاعات برای درک نحوه استفاده از AnythingLLM، اولویتبندی کار روی ویژگیهای جدید و رفع اشکالات، و بهبود عملکرد و پایداری AnythingLLM استفاده میکنیم.
+
+
+### غیرفعال کردن
+
+
+برای غیرفعال کردن تلهمتری، `DISABLE_TELEMETRY` را در تنظیمات .env سرور یا داکر خود روی "true" تنظیم کنید. همچنین میتوانید این کار را در برنامه با رفتن به نوار کناری > `حریم خصوصی` و غیرفعال کردن تلهمتری انجام دهید.
+
+
+### دقیقاً چه چیزی را ردیابی میکنید؟
+
+
+ما فقط جزئیات استفادهای را که به ما در تصمیمگیریهای محصول و نقشه راه کمک میکند، ردیابی میکنیم، به طور خاص:
+
+- نوع نصب شما (Docker یا Desktop)
+- زمانی که سندی اضافه یا حذف میشود. هیچ اطلاعاتی _درباره_ سند نداریم. فقط رویداد ثبت میشود.
+- نوع پایگاه داده برداری در حال استفاده. به ما کمک میکند بدانیم کدام ارائهدهنده بیشتر استفاده میشود.
+- نوع LLM در حال استفاده. به ما کمک میکند محبوبترین انتخاب را بشناسیم.
+- ارسال چت. این معمولترین "رویداد" است و به ما ایدهای از فعالیت روزانه میدهد.
+
+میتوانید این ادعاها را با پیدا کردن تمام مکانهایی که `Telemetry.sendTelemetry` فراخوانی میشود، تأیید کنید. ارائهدهنده تلهمتری [PostHog](https://posthog.com/) است.
+
+[مشاهده همه رویدادهای تلهمتری در کد منبع](https://github.com/search?q=repo%3AMintplex-Labs%2Fanything-llm%20.sendTelemetry\(&type=code)
+
+
+
+
+## 👋 مشارکت
+
+
+
+- ایجاد issue
+- ایجاد PR با فرمت نام شاخه `<شماره issue>-<نام کوتاه>`
+- تأیید از تیم اصلی
+
+
+## 🌟 مشارکتکنندگان
+
+[](https://github.com/mintplex-labs/anything-llm/graphs/contributors)
+
+[](https://star-history.com/#mintplex-labs/anything-llm&Date)
+
+## 🔗 محصولات بیشتر
+
+
+
+- **[VectorAdmin][vector-admin]:** یک رابط کاربری و مجموعه ابزار همهکاره برای مدیریت پایگاههای داده برداری.
+- **[OpenAI Assistant Swarm][assistant-swarm]:** تبدیل کل کتابخانه دستیاران OpenAI به یک ارتش واحد تحت فرمان یک عامل.
+
+
+
+
+[![][back-to-top]](#readme-top)
+
+
+
+---
+
+
+Copyright © 2025 [Mintplex Labs][profile-link].
+This project is [MIT](../LICENSE) licensed.
+
+
+
+[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-222628?style=flat-square
+[profile-link]: https://github.com/mintplex-labs
+[vector-admin]: https://github.com/mintplex-labs/vector-admin
+[assistant-swarm]: https://github.com/Mintplex-Labs/openai-assistant-swarm
+[docker-btn]: ./images/deployBtns/docker.png
+[docker-deploy]: ./docker/HOW_TO_USE_DOCKER.md
+[aws-btn]: ./images/deployBtns/aws.png
+[aws-deploy]: ./cloud-deployments/aws/cloudformation/DEPLOY.md
+[gcp-btn]: https://deploy.cloud.run/button.svg
+[gcp-deploy]: ./cloud-deployments/gcp/deployment/DEPLOY.md
+[do-btn]: https://www.deploytodo.com/do-btn-blue.svg
+[do-deploy]: ./cloud-deployments/digitalocean/terraform/DEPLOY.md
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[railway-btn]: https://railway.app/button.svg
+[railway-deploy]: https://railway.app/template/HNSCS1?referralCode=WFgJkn
+[repocloud-btn]: https://d16t0pc4846x52.cloudfront.net/deploylobe.svg
+[repocloud-deploy]: https://repocloud.io/details/?app_id=276
+[elestio-btn]: https://elest.io/images/logos/deploy-to-elestio-btn.png
+[elestio-deploy]: https://elest.io/open-source/anythingllm
diff --git a/locales/README.ja-JP.md b/locales/README.ja-JP.md
new file mode 100644
index 0000000000000000000000000000000000000000..d6fef0fa5b7bf770d00091829019ce54eb569f81
--- /dev/null
+++ b/locales/README.ja-JP.md
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+
+
+
+ AnythingLLM: あなたが探していたオールインワンAIアプリ。
+ ドキュメントとチャットし、AIエージェントを使用し、高度にカスタマイズ可能で、複数ユーザー対応、面倒な設定は不要です。
+
+
+
+
+
+ |
+
+
+ |
+
+ ドキュメント
+ |
+
+ ホストされたインスタンス
+
+
+
+
+ English · 简体中文 · 日本語
+
+
+
+👉 デスクトップ用AnythingLLM(Mac、Windows、Linux対応)!今すぐダウンロード
+
+
+これは、任意のドキュメント、リソース、またはコンテンツの断片を、チャット中にLLMが参照として使用できるコンテキストに変換できるフルスタックアプリケーションです。このアプリケーションを使用すると、使用するLLMまたはベクトルデータベースを選択し、マルチユーザー管理と権限をサポートできます。
+
+
+
+
+デモを見る!
+
+[](https://youtu.be/f95rGD9trL0)
+
+
+
+### 製品概要
+
+AnythingLLMは、市販のLLMや人気のあるオープンソースLLM、およびベクトルDBソリューションを使用して、妥協のないプライベートChatGPTを構築できるフルスタックアプリケーションです。ローカルで実行することも、リモートでホストすることもでき、提供されたドキュメントと知的にチャットできます。
+
+AnythingLLMは、ドキュメントを`ワークスペース`と呼ばれるオブジェクトに分割します。ワークスペースはスレッドのように機能しますが、ドキュメントのコンテナ化が追加されています。ワークスペースはドキュメントを共有できますが、互いに通信することはないため、各ワークスペースのコンテキストをクリーンに保つことができます。
+
+## AnythingLLMのいくつかのクールな機能
+
+- **マルチユーザーインスタンスのサポートと権限付与**
+- ワークスペース内のエージェント(ウェブを閲覧、コードを実行など)
+- [ウェブサイト用のカスタム埋め込み可能なチャットウィジェット](https://github.com/Mintplex-Labs/anythingllm-embed/blob/main/README.md)
+- 複数のドキュメントタイプのサポート(PDF、TXT、DOCXなど)
+- シンプルなUIからベクトルデータベース内のドキュメントを管理
+- 2つのチャットモード`会話`と`クエリ`。会話は以前の質問と修正を保持します。クエリはドキュメントに対するシンプルなQAです
+- チャット中の引用
+- 100%クラウドデプロイメント対応。
+- 「独自のLLMを持参」モデル。
+- 大規模なドキュメントを管理するための非常に効率的なコスト削減策。巨大なドキュメントやトランスクリプトを埋め込むために一度以上支払うことはありません。他のドキュメントチャットボットソリューションよりも90%コスト効率が良いです。
+- カスタム統合のための完全な開発者API!
+
+### サポートされているLLM、埋め込みモデル、音声モデル、およびベクトルデータベース
+
+**言語学習モデル:**
+
+- [llama.cpp互換の任意のオープンソースモデル](/server/storage/models/README.md#text-generation-llm-selection)
+- [OpenAI](https://openai.com)
+- [OpenAI (汎用)](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [Anthropic](https://www.anthropic.com/)
+- [Google Gemini Pro](https://ai.google.dev/)
+- [Hugging Face (チャットモデル)](https://huggingface.co/)
+- [Ollama (チャットモデル)](https://ollama.ai/)
+- [LM Studio (すべてのモデル)](https://lmstudio.ai)
+- [LocalAi (すべてのモデル)](https://localai.io/)
+- [Together AI (チャットモデル)](https://www.together.ai/)
+- [Fireworks AI (チャットモデル)](https://fireworks.ai/)
+- [Perplexity (チャットモデル)](https://www.perplexity.ai/)
+- [OpenRouter (チャットモデル)](https://openrouter.ai/)
+- [Novita AI (チャットモデル)](https://novita.ai/model-api/product/llm-api?utm_source=github_anything-llm&utm_medium=github_readme&utm_campaign=link)
+- [Mistral](https://mistral.ai/)
+- [Groq](https://groq.com/)
+- [Cohere](https://cohere.com/)
+- [KoboldCPP](https://github.com/LostRuins/koboldcpp)
+- [PPIO](https://ppinfra.com?utm_source=github_anything-llm)
+- [CometAPI (チャットモデル)](https://api.cometapi.com/)
+
+**埋め込みモデル:**
+
+- [AnythingLLMネイティブ埋め込み](/server/storage/models/README.md)(デフォルト)
+- [OpenAI](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [LocalAi (すべて)](https://localai.io/)
+- [Ollama (すべて)](https://ollama.ai/)
+- [LM Studio (すべて)](https://lmstudio.ai)
+- [Cohere](https://cohere.com/)
+
+**音声変換モデル:**
+
+- [AnythingLLM内蔵](https://github.com/Mintplex-Labs/anything-llm/tree/master/server/storage/models#audiovideo-transcription)(デフォルト)
+- [OpenAI](https://openai.com/)
+
+**TTS(テキストから音声へ)サポート:**
+
+- ネイティブブラウザ内蔵(デフォルト)
+- [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech/voice-options)
+- [ElevenLabs](https://elevenlabs.io/)
+
+**STT(音声からテキストへ)サポート:**
+
+- ネイティブブラウザ内蔵(デフォルト)
+
+**ベクトルデータベース:**
+
+- [LanceDB](https://github.com/lancedb/lancedb)(デフォルト)
+- [PGVector](https://github.com/pgvector/pgvector)
+- [Astra DB](https://www.datastax.com/products/datastax-astra)
+- [Pinecone](https://pinecone.io)
+- [Chroma](https://trychroma.com)
+- [Weaviate](https://weaviate.io)
+- [QDrant](https://qdrant.tech)
+- [Milvus](https://milvus.io)
+- [Zilliz](https://zilliz.com)
+
+### 技術概要
+
+このモノレポは、主に3つのセクションで構成されています:
+
+- `frontend`: LLMが使用できるすべてのコンテンツを簡単に作成および管理できるviteJS + Reactフロントエンド。
+- `server`: すべてのインタラクションを処理し、すべてのベクトルDB管理およびLLMインタラクションを行うNodeJS expressサーバー。
+- `collector`: UIからドキュメントを処理および解析するNodeJS expressサーバー。
+- `docker`: Dockerの指示およびビルドプロセス + ソースからのビルド情報。
+- `embed`: [埋め込みウィジェット](../embed/README.md)の生成に特化したコード。
+
+## 🛳 セルフホスティング
+
+Mintplex Labsおよびコミュニティは、AnythingLLMをローカルで実行できる多数のデプロイメント方法、スクリプト、テンプレートを維持しています。以下の表を参照して、お好みの環境でのデプロイ方法を読むか、自動デプロイを行ってください。
+| Docker | AWS | GCP | Digital Ocean | Render.com |
+|----------------------------------------|----|-----|---------------|------------|
+| [![Docker上でデプロイ][docker-btn]][docker-deploy] | [![AWS上でデプロイ][aws-btn]][aws-deploy] | [![GCP上でデプロイ][gcp-btn]][gcp-deploy] | [![DigitalOcean上でデプロイ][do-btn]][do-deploy] | [![Render.com上でデプロイ][render-btn]][render-deploy] |
+
+| Railway |
+| --------------------------------------------------- |
+| [![Railway上でデプロイ][railway-btn]][railway-deploy] |
+
+[Dockerを使用せずに本番環境のAnythingLLMインスタンスを設定する →](../BARE_METAL.md)
+
+## 開発環境のセットアップ方法
+
+- `yarn setup` 各アプリケーションセクションに必要な`.env`ファイルを入力します(リポジトリのルートから)。
+ - 次に進む前にこれらを入力してください。`server/.env.development`が入力されていないと正しく動作しません。
+- `yarn dev:server` ローカルでサーバーを起動します(リポジトリのルートから)。
+- `yarn dev:frontend` ローカルでフロントエンドを起動します(リポジトリのルートから)。
+- `yarn dev:collector` ドキュメントコレクターを実行します(リポジトリのルートから)。
+
+[ドキュメントについて学ぶ](../server/storage/documents/DOCUMENTS.md)
+
+[ベクトルキャッシュについて学ぶ](../server/storage/vector-cache/VECTOR_CACHE.md)
+
+## 貢献する方法
+
+- issueを作成する
+- `
-`の形式のブランチ名でPRを作成する
+- マージしましょう
+
+## テレメトリーとプライバシー
+
+Mintplex Labs Inc.によって開発されたAnythingLLMには、匿名の使用情報を収集するテレメトリー機能が含まれています。
+
+
+AnythingLLMのテレメトリーとプライバシーについての詳細
+
+### なぜ?
+
+この情報を使用して、AnythingLLMの使用方法を理解し、新機能とバグ修正の優先順位を決定し、AnythingLLMのパフォーマンスと安定性を向上させるのに役立てます。
+
+### オプトアウト
+
+サーバーまたはdockerの.env設定で`DISABLE_TELEMETRY`を「true」に設定して、テレメトリーからオプトアウトします。アプリ内でも、サイドバー > `プライバシー`に移動してテレメトリーを無効にすることができます。
+
+### 明示的に追跡するもの
+
+製品およびロードマップの意思決定に役立つ使用詳細のみを追跡します。具体的には:
+
+- インストールのタイプ(Dockerまたはデスクトップ)
+- ドキュメントが追加または削除されたとき。ドキュメントについての情報はありません。イベントが発生したことのみを知ります。これにより、使用状況を把握できます。
+- 使用中のベクトルデータベースのタイプ。どのベクトルデータベースプロバイダーが最も使用されているかを知り、更新があったときに優先して変更を行います。
+- 使用中のLLMのタイプ。最も人気のある選択肢を知り、更新があったときに優先して変更を行います。
+- チャットが送信された。これは最も一般的な「イベント」であり、すべてのインストールでのこのプロジェクトの日常的な「アクティビティ」についてのアイデアを提供します。再び、イベントのみが送信され、チャット自体の性質や内容に関する情報はありません。
+
+これらの主張を検証するには、`Telemetry.sendTelemetry`が呼び出されるすべての場所を見つけてください。また、これらのイベントは出力ログに書き込まれるため、送信された具体的なデータも確認できます。IPアドレスやその他の識別情報は収集されません。テレメトリープロバイダーは[PostHog](https://posthog.com/)です。
+
+[ソースコード内のすべてのテレメトリーイベントを表示](https://github.com/search?q=repo%3AMintplex-Labs%2Fanything-llm%20.sendTelemetry\(&type=code)
+
+
+
+## 🔗 その他の製品
+
+- **[VectorAdmin][vector-admin]**:ベクトルデータベースを管理するためのオールインワンGUIおよびツールスイート。
+- **[OpenAI Assistant Swarm][assistant-swarm]**:単一のエージェントから指揮できるOpenAIアシスタントの軍隊に、ライブラリ全体を変換します。
+
+
+
+[![][back-to-top]](#readme-top)
+
+
+
+---
+
+Copyright © 2025 [Mintplex Labs][profile-link]。
+このプロジェクトは[MIT](https://github.com/Mintplex-Labs/anything-llm/blob/master/LICENSE)ライセンスの下でライセンスされています。
+
+
+
+[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-222628?style=flat-square
+[profile-link]: https://github.com/mintplex-labs
+[vector-admin]: https://github.com/mintplex-labs/vector-admin
+[assistant-swarm]: https://github.com/Mintplex-Labs/openai-assistant-swarm
+[docker-btn]: ./images/deployBtns/docker.png
+[docker-deploy]: ./docker/HOW_TO_USE_DOCKER.md
+[aws-btn]: ./images/deployBtns/aws.png
+[aws-deploy]: ./cloud-deployments/aws/cloudformation/DEPLOY.md
+[gcp-btn]: https://deploy.cloud.run/button.svg
+[gcp-deploy]: ./cloud-deployments/gcp/deployment/DEPLOY.md
+[do-btn]: https://www.deploytodo.com/do-btn-blue.svg
+[do-deploy]: ./cloud-deployments/digitalocean/terraform/DEPLOY.md
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[railway-btn]: https://railway.app/button.svg
+[railway-deploy]: https://railway.app/template/HNSCS1?referralCode=WFgJkn
diff --git a/locales/README.tr-TR.md b/locales/README.tr-TR.md
new file mode 100644
index 0000000000000000000000000000000000000000..9f539779503c52494c08fa208c1f0b184fcf4771
--- /dev/null
+++ b/locales/README.tr-TR.md
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+
+
+
+
+
+AnythingLLM: Aradığınız hepsi bir arada yapay zeka uygulaması.
+Belgelerinizle sohbet edin, yapay zeka ajanlarını kullanın, son derece özelleştirilebilir, çok kullanıcılı ve zahmetsiz kurulum!
+
+
+
+
+
+ |
+
+
+ |
+
+ Docs
+ |
+
+ Hosted Instance
+
+
+
+
+ English · 简体中文 · 日本語 · Turkish
+
+
+
+
+👉 Masaüstü için AnythingLLM (Mac, Windows ve Linux)! Şimdi İndir
+
+
+Herhangi bir belgeyi, kaynağı veya içeriği sohbet sırasında herhangi bir büyük dil modelinin referans olarak kullanabileceği bir bağlama dönüştürmenizi sağlayan tam kapsamlı bir uygulama. Bu uygulama, kullanmak istediğiniz LLM veya Vektör Veritabanını seçmenize olanak tanırken, çok kullanıcılı yönetim ve yetkilendirme desteği de sunar.
+
+
+
+
+Demoyu izle!
+
+[](https://youtu.be/f95rGD9trL0)
+
+
+
+### Ürün Genel Bakışı
+
+AnythingLLM, ticari hazır büyük dil modellerini veya popüler açık kaynak LLM'leri ve vektör veritabanı çözümlerini kullanarak, hiçbir ödün vermeden özel bir ChatGPT oluşturmanıza olanak tanıyan tam kapsamlı bir uygulamadır. Bu uygulamayı yerel olarak çalıştırabilir veya uzaktan barındırarak sağladığınız belgelerle akıllı sohbetler gerçekleştirebilirsiniz.
+
+AnythingLLM, belgelerinizi **"çalışma alanları" (workspaces)** adı verilen nesnelere ayırır. Bir çalışma alanı, bir sohbet dizisi gibi çalışır ancak belgelerinizi kapsülleyen bir yapı sunar. Çalışma alanları belgeleri paylaşabilir, ancak birbirleriyle iletişim kurmaz, böylece her çalışma alanının bağlamını temiz tutabilirsiniz.
+
+## AnythingLLM’in Harika Özellikleri
+
+- 🆕 [**Özel Yapay Zeka Ajanları**](https://docs.anythingllm.com/agent/custom/introduction)
+- 🆕 [**Kod yazmadan AI Ajanı oluşturma aracı**](https://docs.anythingllm.com/agent-flows/overview)
+- 🖼️ **Çoklu-mod desteği (hem kapalı kaynak hem de açık kaynak LLM'ler!)**
+- 👤 Çok kullanıcılı destek ve yetkilendirme _(Yalnızca Docker sürümünde)_
+- 🦾 Çalışma alanı içinde ajanlar (web'de gezinme vb.)
+- 💬 [Web sitenize gömülebilir özel sohbet aracı](https://github.com/Mintplex-Labs/anythingllm-embed/blob/main/README.md) _(Yalnızca Docker sürümünde)_
+- 📖 Çoklu belge türü desteği (PDF, TXT, DOCX vb.)
+- Sade ve kullanışlı sohbet arayüzü, sürükle-bırak özelliği ve net kaynak gösterimi.
+- %100 bulut konuşlandırmaya hazır.
+- [Tüm popüler kapalı ve açık kaynak LLM sağlayıcılarıyla](#supported-llms-embedder-models-speech-models-and-vector-databases) uyumlu.
+- Büyük belgeleri yönetirken zaman ve maliyet tasarrufu sağlayan dahili optimizasyonlar.
+- Özel entegrasyonlar için tam kapsamlı Geliştirici API’si.
+- Ve çok daha fazlası... Kurup keşfedin!
+
+### Desteklenen LLM'ler, Embedding Modelleri, Konuşma Modelleri ve Vektör Veritabanları
+
+**Büyük Dil Modelleri (LLMs):**
+
+- [Any open-source llama.cpp compatible model](/server/storage/models/README.md#text-generation-llm-selection)
+- [OpenAI](https://openai.com)
+- [OpenAI (Generic)](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [AWS Bedrock](https://aws.amazon.com/bedrock/)
+- [Anthropic](https://www.anthropic.com/)
+- [NVIDIA NIM (chat models)](https://build.nvidia.com/explore/discover)
+- [Google Gemini Pro](https://ai.google.dev/)
+- [Hugging Face (chat models)](https://huggingface.co/)
+- [Ollama (chat models)](https://ollama.ai/)
+- [LM Studio (all models)](https://lmstudio.ai)
+- [LocalAi (all models)](https://localai.io/)
+- [Together AI (chat models)](https://www.together.ai/)
+- [Fireworks AI (chat models)](https://fireworks.ai/)
+- [Perplexity (chat models)](https://www.perplexity.ai/)
+- [OpenRouter (chat models)](https://openrouter.ai/)
+- [DeepSeek (chat models)](https://deepseek.com/)
+- [Mistral](https://mistral.ai/)
+- [Groq](https://groq.com/)
+- [Cohere](https://cohere.com/)
+- [KoboldCPP](https://github.com/LostRuins/koboldcpp)
+- [LiteLLM](https://github.com/BerriAI/litellm)
+- [Text Generation Web UI](https://github.com/oobabooga/text-generation-webui)
+- [Apipie](https://apipie.ai/)
+- [xAI](https://x.ai/)
+- [Novita AI (chat models)](https://novita.ai/model-api/product/llm-api?utm_source=github_anything-llm&utm_medium=github_readme&utm_campaign=link)
+- [PPIO](https://ppinfra.com?utm_source=github_anything-llm)
+
+**Embedder modelleri:**
+
+- [AnythingLLM Native Embedder](/server/storage/models/README.md) (default)
+- [OpenAI](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [LocalAi (all)](https://localai.io/)
+- [Ollama (all)](https://ollama.ai/)
+- [LM Studio (all)](https://lmstudio.ai)
+- [Cohere](https://cohere.com/)
+
+**Ses Transkripsiyon Modelleri:**
+
+- [AnythingLLM Built-in](https://github.com/Mintplex-Labs/anything-llm/tree/master/server/storage/models#audiovideo-transcription) (default)
+- [OpenAI](https://openai.com/)
+
+**TTS (text-to-speech) desteği:**
+
+- Native Browser Built-in (default)
+- [PiperTTSLocal - runs in browser](https://github.com/rhasspy/piper)
+- [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech/voice-options)
+- [ElevenLabs](https://elevenlabs.io/)
+- Any OpenAI Compatible TTS service.
+
+**STT (speech-to-text) desteği:**
+
+- Native Browser Built-in (default)
+
+**Vektör Databases:**
+
+- [LanceDB](https://github.com/lancedb/lancedb) (default)
+- [PGVector](https://github.com/pgvector/pgvector)
+- [Astra DB](https://www.datastax.com/products/datastax-astra)
+- [Pinecone](https://pinecone.io)
+- [Chroma](https://trychroma.com)
+- [Weaviate](https://weaviate.io)
+- [Qdrant](https://qdrant.tech)
+- [Milvus](https://milvus.io)
+- [Zilliz](https://zilliz.com)
+
+### Teknik Genel Bakış
+
+Bu monorepo üç ana bölümden oluşmaktadır:
+
+- **`frontend`**: ViteJS + React tabanlı bir ön yüz, LLM'in kullanabileceği tüm içeriği kolayca oluşturup yönetmenizi sağlar.
+- **`server`**: NodeJS ve Express tabanlı bir sunucu, tüm etkileşimleri yönetir ve vektör veritabanı işlemleri ile LLM entegrasyonlarını gerçekleştirir.
+- **`collector`**: Kullanıcı arayüzünden gelen belgeleri işleyen ve ayrıştıran NodeJS Express tabanlı bir sunucu.
+- **`docker`**: Docker kurulum talimatları, derleme süreci ve kaynak koddan nasıl derleneceğine dair bilgiler içerir.
+- **`embed`**: [Web gömme widget’ı](https://github.com/Mintplex-Labs/anythingllm-embed) oluşturma ve entegrasyonu için alt modül.
+- **`browser-extension`**: [Chrome tarayıcı eklentisi](https://github.com/Mintplex-Labs/anythingllm-extension) için alt modül.
+
+## 🛳 Kendi Sunucunuzda Barındırma
+
+Mintplex Labs ve topluluk, AnythingLLM'i yerel olarak çalıştırmak için çeşitli dağıtım yöntemleri, betikler ve şablonlar sunmaktadır. Aşağıdaki tabloya göz atarak tercih ettiğiniz ortamda nasıl dağıtım yapabileceğinizi öğrenebilir veya otomatik dağıtım seçeneklerini keşfedebilirsiniz.
+| Docker | AWS | GCP | Digital Ocean | Render.com |
+|----------------------------------------|----|-----|---------------|------------|
+| [![Deploy on Docker][docker-btn]][docker-deploy] | [![Deploy on AWS][aws-btn]][aws-deploy] | [![Deploy on GCP][gcp-btn]][gcp-deploy] | [![Deploy on DigitalOcean][do-btn]][do-deploy] | [![Deploy on Render.com][render-btn]][render-deploy] |
+
+| Railway | RepoCloud | Elestio |
+| --- | --- | --- |
+| [![Deploy on Railway][railway-btn]][railway-deploy] | [![Deploy on RepoCloud][repocloud-btn]][repocloud-deploy] | [![Deploy on Elestio][elestio-btn]][elestio-deploy] |
+
+[veya Docker kullanmadan üretim ortamında AnythingLLM kurun →](../BARE_METAL.md)
+
+## Geliştirme İçin Kurulum
+
+- `yarn setup` → Uygulamanın her bileşeni için gerekli `.env` dosyalarını oluşturur (repo’nun kök dizininden çalıştırılmalıdır).
+ - Devam etmeden önce bu dosyaları doldurun. **Özellikle `server/.env.development` dosyasının doldurulduğundan emin olun**, aksi takdirde sistem düzgün çalışmaz.
+- `yarn dev:server` → Sunucuyu yerel olarak başlatır (repo’nun kök dizininden çalıştırılmalıdır).
+- `yarn dev:frontend` → Ön yüzü yerel olarak çalıştırır (repo’nun kök dizininden çalıştırılmalıdır).
+- `yarn dev:collector` → Belge toplayıcıyı çalıştırır (repo’nun kök dizininden çalıştırılmalıdır).
+
+[Belgeler hakkında bilgi edinin](../server/storage/documents/DOCUMENTS.md)
+
+[Vektör önbellekleme hakkında bilgi edinin](../server/storage/vector-cache/VECTOR_CACHE.md)
+
+## Harici Uygulamalar ve Entegrasyonlar
+
+_Bu uygulamalar Mintplex Labs tarafından yönetilmemektedir, ancak AnythingLLM ile uyumludur. Burada listelenmeleri bir onay anlamına gelmez._
+
+- [Midori AI Alt Sistem Yöneticisi](https://io.midori-ai.xyz/subsystem/anythingllm/) - Docker konteyner teknolojisini kullanarak yapay zeka sistemlerini verimli bir şekilde dağıtmanın pratik bir yolu.
+- [Coolify](https://coolify.io/docs/services/anythingllm/) - Tek tıklamayla AnythingLLM dağıtımı yapmanıza olanak tanır.
+- [GPTLocalhost for Microsoft Word](https://gptlocalhost.com/demo/) - AnythingLLM’i Microsoft Word içinde kullanmanıza olanak tanıyan yerel bir Word eklentisi.
+
+## Telemetri ve Gizlilik
+
+Mintplex Labs Inc. tarafından geliştirilen AnythingLLM, anonim kullanım bilgilerini toplayan bir telemetri özelliği içermektedir.
+
+
+AnythingLLM için Telemetri ve Gizlilik hakkında daha fazla bilgi
+
+### Neden?
+
+Bu bilgileri, AnythingLLM’in nasıl kullanıldığını anlamak, yeni özellikler ve hata düzeltmelerine öncelik vermek ve uygulamanın performansını ve kararlılığını iyileştirmek için kullanıyoruz.
+
+### Telemetriden Çıkış Yapma (Opt-Out)
+
+Sunucu veya Docker `.env` ayarlarında `DISABLE_TELEMETRY` değerini "true" olarak ayarlayarak telemetriyi devre dışı bırakabilirsiniz. Ayrıca, uygulama içinde **Kenar Çubuğu > Gizlilik** bölümüne giderek de bu özelliği kapatabilirsiniz.
+
+### Hangi Verileri Açıkça Takip Ediyoruz?
+
+Yalnızca ürün ve yol haritası kararlarını almamıza yardımcı olacak kullanım detaylarını takip ediyoruz:
+
+- Kurulum türü (Docker veya Masaüstü)
+- Bir belgenin eklenme veya kaldırılma olayı. **Belgenin içeriği hakkında hiçbir bilgi toplanmaz**, yalnızca olayın gerçekleştiği kaydedilir. Bu, kullanım sıklığını anlamamıza yardımcı olur.
+- Kullanılan vektör veritabanı türü. Hangi sağlayıcının daha çok tercih edildiğini belirlemek için bu bilgiyi topluyoruz.
+- Kullanılan LLM türü. En popüler modelleri belirleyerek bu sağlayıcılara öncelik verebilmemizi sağlar.
+- Sohbet başlatılması. Bu en sık gerçekleşen "olay" olup, projenin günlük etkinliği hakkında genel bir fikir edinmemize yardımcı olur. **Yalnızca olay kaydedilir, sohbetin içeriği veya doğası hakkında hiçbir bilgi toplanmaz.**
+
+Bu verileri doğrulamak için kod içinde **`Telemetry.sendTelemetry` çağrılarını** inceleyebilirsiniz. Ayrıca, bu olaylar günlük kaydına yazıldığı için hangi verilerin gönderildiğini görebilirsiniz (eğer etkinleştirilmişse). **IP adresi veya diğer tanımlayıcı bilgiler toplanmaz.** Telemetri sağlayıcısı, açık kaynaklı bir telemetri toplama hizmeti olan [PostHog](https://posthog.com/)‘dur.
+
+[Kaynak kodda tüm telemetri olaylarını görüntüle](https://github.com/search?q=repo%3AMintplex-Labs%2Fanything-llm%20.sendTelemetry\(&type=code)
+
+
+
+
+## 👋 Katkıda Bulunma
+
+- Bir **issue** oluşturun.
+- `-` formatında bir **PR (Pull Request)** oluşturun.
+- Çekirdek ekipten **LGTM (Looks Good To Me)** onayı alın.
+
+## 🌟 Katkıda Bulunanlar
+
+[](https://github.com/mintplex-labs/anything-llm/graphs/contributors)
+
+[](https://star-history.com/#mintplex-labs/anything-llm&Date)
+
+## 🔗 Diğer Ürünler
+
+- **[VectorAdmin][vector-admin]:** Vektör veritabanlarını yönetmek için hepsi bir arada GUI ve araç paketi.
+- **[OpenAI Assistant Swarm][assistant-swarm]:** Tüm OpenAI asistanlarınızı tek bir ajan tarafından yönetilen bir yapay zeka ordusuna dönüştürün.
+
+
+
+[![][back-to-top]](#readme-top)
+
+
+
+---
+
+Telif Hakkı © 2025 [Mintplex Labs][profile-link].
+Bu proje [MIT](../LICENSE) lisansı ile lisanslanmıştır.
+
+
+
+[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-222628?style=flat-square
+[profile-link]: https://github.com/mintplex-labs
+[vector-admin]: https://github.com/mintplex-labs/vector-admin
+[assistant-swarm]: https://github.com/Mintplex-Labs/openai-assistant-swarm
+[docker-btn]: ./images/deployBtns/docker.png
+[docker-deploy]: ./docker/HOW_TO_USE_DOCKER.md
+[aws-btn]: ./images/deployBtns/aws.png
+[aws-deploy]: ./cloud-deployments/aws/cloudformation/DEPLOY.md
+[gcp-btn]: https://deploy.cloud.run/button.svg
+[gcp-deploy]: ./cloud-deployments/gcp/deployment/DEPLOY.md
+[do-btn]: https://www.deploytodo.com/do-btn-blue.svg
+[do-deploy]: ./cloud-deployments/digitalocean/terraform/DEPLOY.md
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[railway-btn]: https://railway.app/button.svg
+[railway-deploy]: https://railway.app/template/HNSCS1?referralCode=WFgJkn
+[repocloud-btn]: https://d16t0pc4846x52.cloudfront.net/deploylobe.svg
+[repocloud-deploy]: https://repocloud.io/details/?app_id=276
+[elestio-btn]: https://elest.io/images/logos/deploy-to-elestio-btn.png
+[elestio-deploy]: https://elest.io/open-source/anythingllm
diff --git a/locales/README.zh-CN.md b/locales/README.zh-CN.md
new file mode 100644
index 0000000000000000000000000000000000000000..aa328351449c0817917065c4f1687ede3b392b05
--- /dev/null
+++ b/locales/README.zh-CN.md
@@ -0,0 +1,289 @@
+
+
+
+
+
+
+
+
+
+
+
+ AnythingLLM: 您一直在寻找的全方位AI应用程序。
+ 与您的文档聊天,使用AI代理,高度可配置,多用户,无需繁琐的设置。
+
+
+
+
+
+ |
+
+
+ |
+
+ 文档
+ |
+
+ 托管实例
+
+
+
+
+ English · 简体中文 · 日本語
+
+
+
+👉 适用于桌面(Mac、Windows和Linux)的AnythingLLM!立即下载
+
+
+这是一个全栈应用程序,可以将任何文档、资源(如网址链接、音频、视频)或内容片段转换为上下文,以便任何大语言模型(LLM)在聊天期间作为参考使用。此应用程序允许您选择使用哪个LLM或向量数据库,同时支持多用户管理并设置不同权限。
+
+
+
+
+观看演示视频!
+
+[](https://youtu.be/f95rGD9trL0)
+
+
+
+### 产品概览
+
+AnythingLLM是一个全栈应用程序,您可以使用现成的商业大语言模型或流行的开源大语言模型,再结合向量数据库解决方案构建一个私有ChatGPT,不再受制于人:您可以本地运行,也可以远程托管,并能够与您提供的任何文档智能聊天。
+
+AnythingLLM将您的文档划分为称为`workspaces` (工作区)的对象。工作区的功能类似于线程,同时增加了文档的容器化。工作区可以共享文档,但工作区之间的内容不会互相干扰或污染,因此您可以保持每个工作区的上下文清晰。
+
+## AnythingLLM的一些酷炫特性
+- 🆕 [**完全兼容 MCP**](https://docs.anythingllm.com/mcp-compatibility/overview)
+- 🆕 [**无代码AI代理构建器**](https://docs.anythingllm.com/agent-flows/overview)
+- 🖼️ **多用户实例支持和权限管理(支持封闭源和开源LLM!)**
+- [**自定义人工智能代理**](https://docs.anythingllm.com/agent/custom/introduction)
+- 👤 多用户实例支持和权限管理 _仅限Docker版本_
+- 🦾 工作区内的智能体(浏览网页、运行代码等)
+- 💬 [为您的网站定制的可嵌入聊天窗口](https://github.com/Mintplex-Labs/anythingllm-embed/blob/main/README.md)
+- 📖 支持多种文档类型(PDF、TXT、DOCX等)
+- 带有拖放功能和清晰引用的简洁聊天界面。
+- 100%云部署就绪。
+- 兼容所有主流的[闭源和开源大语言模型提供商](#支持的llm嵌入模型转录模型和向量数据库)。
+- 内置节省成本和时间的机制,用于处理超大文档,优于任何其他聊天界面。
+- 全套的开发人员API,用于自定义集成!
+- 而且还有更多精彩功能……安装后亲自体验吧!
+
+### 支持的LLM、嵌入模型、转录模型和向量数据库
+
+**支持的LLM:**
+
+- [任何与llama.cpp兼容的开源模型](/server/storage/models/README.md#text-generation-llm-selection)
+- [OpenAI](https://openai.com)
+- [OpenAI (通用)](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [AWS Bedrock](https://aws.amazon.com/bedrock/)
+- [Anthropic](https://www.anthropic.com/)
+- [NVIDIA NIM (聊天模型)](https://build.nvidia.com/explore/discover)
+- [Google Gemini Pro](https://ai.google.dev/)
+- [Hugging Face (聊天模型)](https://huggingface.co/)
+- [Ollama (聊天模型)](https://ollama.ai/)
+- [LM Studio (所有模型)](https://lmstudio.ai)
+- [LocalAI (所有模型)](https://localai.io/)
+- [Together AI (聊天模型)](https://www.together.ai/)
+- [Fireworks AI (聊天模型)](https://fireworks.ai/)
+- [Perplexity (聊天模型)](https://www.perplexity.ai/)
+- [OpenRouter (聊天模型)](https://openrouter.ai/)
+- [DeepSeek (聊天模型)](https://deepseek.com/)
+- [Mistral](https://mistral.ai/)
+- [Groq](https://groq.com/)
+- [Cohere](https://cohere.com/)
+- [KoboldCPP](https://github.com/LostRuins/koboldcpp)
+- [LiteLLM](https://github.com/BerriAI/litellm)
+- [Text Generation Web UI](https://github.com/oobabooga/text-generation-webui)
+- [Apipie](https://apipie.ai/)
+- [xAI](https://x.ai/)
+- [Novita AI (聊天模型)](https://novita.ai/model-api/product/llm-api?utm_source=github_anything-llm&utm_medium=github_readme&utm_campaign=link)
+- [PPIO (聊天模型)](https://ppinfra.com?utm_source=github_anything-llm)
+- [CometAPI (聊天模型)](https://api.cometapi.com/)
+
+**支持的嵌入模型:**
+
+- [AnythingLLM原生嵌入器](/server/storage/models/README.md)(默认)
+- [OpenAI](https://openai.com)
+- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service)
+- [LocalAI (全部)](https://localai.io/)
+- [Ollama (全部)](https://ollama.ai/)
+- [LM Studio (全部)](https://lmstudio.ai)
+- [Cohere](https://cohere.com/)
+
+**支持的转录模型:**
+
+- [AnythingLLM内置](https://github.com/Mintplex-Labs/anything-llm/tree/master/server/storage/models#audiovideo-transcription) (默认)
+- [OpenAI](https://openai.com/)
+
+**TTS (文本转语音) 支持:**
+
+- 浏览器内置(默认)
+- [PiperTTSLocal - 在浏览器中运行](https://github.com/rhasspy/piper)
+- [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech/voice-options)
+- [ElevenLabs](https://elevenlabs.io/)
+- 任何与 OpenAI 兼容的 TTS 服务
+
+**STT (语音转文本) 支持:**
+
+- 浏览器内置(默认)
+
+**支持的向量数据库:**
+
+- [LanceDB](https://github.com/lancedb/lancedb) (默认)
+- [PGVector](https://github.com/pgvector/pgvector)
+- [Astra DB](https://www.datastax.com/products/datastax-astra)
+- [Pinecone](https://pinecone.io)
+- [Chroma](https://trychroma.com)
+- [Weaviate](https://weaviate.io)
+- [QDrant](https://qdrant.tech)
+- [Milvus](https://milvus.io)
+- [Zilliz](https://zilliz.com)
+
+### 技术概览
+
+这个单库由六个主要部分组成:
+
+- `frontend`: 一个 viteJS + React 前端,您可以运行它来轻松创建和管理LLM可以使用的所有内容。
+- `server`: 一个 NodeJS express 服务器,用于处理所有交互并进行所有向量数据库管理和 LLM 交互。
+- `collector`: NodeJS express 服务器,用于从UI处理和解析文档。
+- `docker`: Docker 指令和构建过程 + 从源代码构建的信息。
+- `embed`: 用于生成和创建[网页嵌入组件](https://github.com/Mintplex-Labs/anythingllm-embed)的子模块.
+- `browser-extension`: 用于[Chrome 浏览器扩展](https://github.com/Mintplex-Labs/anythingllm-extension)的子模块.
+
+## 🛳 自托管
+
+Mintplex Labs和社区维护了许多部署方法、脚本和模板,您可以使用它们在本地运行AnythingLLM。请参阅下面的表格,了解如何在您喜欢的环境上部署,或自动部署。
+| Docker | AWS | GCP | Digital Ocean | Render.com |
+|----------------------------------------|----|-----|---------------|------------|
+| [![在 Docker 上部署][docker-btn]][docker-deploy] | [![在 AWS 上部署][aws-btn]][aws-deploy] | [![在 GCP 上部署][gcp-btn]][gcp-deploy] | [![在DigitalOcean上部署][do-btn]][do-deploy] | [![在 Render.com 上部署][render-btn]][render-deploy] |
+
+| Railway | RepoCloud | Elestio |
+| --- | --- | --- |
+| [![在 Railway 上部署][railway-btn]][railway-deploy] | [![在 RepoCloud 上部署][repocloud-btn]][repocloud-deploy] | [![在 Elestio 上部署][elestio-btn]][elestio-deploy] |
+
+[其他方案:不使用Docker配置AnythingLLM实例 →](../BARE_METAL.md)
+
+## 如何设置开发环境
+
+- `yarn setup` 填充每个应用程序部分所需的 `.env` 文件(从仓库的根目录)。
+ - 在开始下一步之前,先填写这些信息`server/.env.development`,不然代码无法正常执行。
+- `yarn dev:server` 在本地启动服务器(从仓库的根目录)。
+- `yarn dev:frontend` 在本地启动前端(从仓库的根目录)。
+- `yarn dev:collector` 然后运行文档收集器(从仓库的根目录)。
+
+[了解文档](../server/storage/documents/DOCUMENTS.md)
+
+[了解向量缓存](../server/storage/vector-cache/VECTOR_CACHE.md)
+
+## 外部应用与集成
+
+_以下是一些与 AnythingLLM 兼容的应用程序,但并非由 Mintplex Labs 维护。列在此处并不代表官方背书。_
+
+- [Midori AI 子系统管理器 - 使用 Docker 容器技术高效部署 AI 系统的简化方式](https://io.midori-ai.xyz/subsystem/anythingllm/) - 使用 Docker 容器技术高效部署 AI 系统的简化方式。
+- [Coolify](https://coolify.io/docs/services/anythingllm/) - 一键部署 AnythingLLM。
+- [适用于 Microsoft Word 的 GPTLocalhost](https://gptlocalhost.com/demo/) - 一个本地 Word 插件,让你可以在 Microsoft Word 中使用 AnythingLLM。
+
+## 远程信息收集与隐私保护
+
+由 Mintplex Labs Inc 开发的 AnythingLLM 包含一个收集匿名使用信息的 Telemetry 功能。
+
+
+有关 AnythingLLM 的远程信息收集与隐私保护更多信息
+
+
+
+
+### 为什么收集信息?
+
+我们使用这些信息来帮助我们理解 AnythingLLM 的使用情况,帮助我们确定新功能和错误修复的优先级,并帮助我们提高 AnythingLLM 的性能和稳定性。
+
+### 怎样关闭
+
+在服务器或 Docker 的 .env 设置中将 `DISABLE_TELEMETRY` 设置为 "true",即可选择不参与遥测数据收集。你也可以在应用内通过以下路径操作:侧边栏 > `Privacy` (隐私) > 关闭遥测功能。
+
+### 你们跟踪收集哪些信息?
+
+我们只会跟踪有助于我们做出产品和路线图决策的使用细节,具体包括:
+
+- 您的安装方式(Docker或桌面版)
+- 文档被添加或移除的时间。但不包括文档内的具体内容。我们只关注添加或移除文档这个行为。这些信息能让我们了解到文档功能的使用情况。
+- 使用中的向量数据库类型。让我们知道哪个向量数据库最受欢迎,并在后续更新中优先考虑相应的数据库。
+- 使用中的LLM类型。让我们知道谁才是最受欢迎的LLM模型,并在后续更新中优先考虑相应模型。
+- 信息被`发送`出去。这是最常规的“事件/行为/event”,并让我们了解到所有安装了这个项目的每日活动情况。同样,只收集`发送`这个行为的信息,我们不会收集关于聊天本身的性质或内容的任何信息。
+
+您可以通过查找所有调用`Telemetry.sendTelemetry`的位置来验证这些声明。此外,如果启用,这些事件也会被写入输出日志,因此您也可以看到发送了哪些具体数据。**IP或其他识别信息不会被收集**。Telemetry远程信息收集的方案来自[PostHog](https://posthog.com/) - 一个开源的远程信息收集服务。
+
+我们非常重视隐私,且不用烦人的弹窗问卷来获取反馈,希望你能理解为什么我们想要知道该工具的使用情况,这样我们才能打造真正值得使用的产品。所有匿名数据 _绝不会_ 与任何第三方共享。
+
+[在源代码中查看所有信息收集活动](https://github.com/search?q=repo%3AMintplex-Labs%2Fanything-llm%20.sendTelemetry\(&type=code)
+
+
+
+## 👋 如何贡献
+
+- 创建 issue
+- 创建 PR,分支名称格式为 `-`
+- 合并
+
+## 💖 赞助商
+
+### 高级赞助商
+
+
+
+
+
+
+
+### 所有赞助商
+
+
+
+## 🌟 贡献者们
+
+[](https://github.com/mintplex-labs/anything-llm/graphs/contributors)
+
+[](https://star-history.com/#mintplex-labs/anything-llm&Date)
+
+## 🔗 更多产品
+
+- **[VectorAdmin][vector-admin]**:一个用于管理向量数据库的全方位图形用户界面和工具套件。
+- **[OpenAI Assistant Swarm][assistant-swarm]**:一个智能体就可以管理您所有的OpenAI助手。
+
+
+
+[![][back-to-top]](#readme-top)
+
+
+
+---
+
+版权所有 © 2025 [Mintplex Labs][profile-link]。
+本项目采用[MIT](https://github.com/Mintplex-Labs/anything-llm/blob/master/LICENSE)许可证。
+
+
+
+[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-222628?style=flat-square
+[profile-link]: https://github.com/mintplex-labs
+[vector-admin]: https://github.com/mintplex-labs/vector-admin
+[assistant-swarm]: https://github.com/Mintplex-Labs/openai-assistant-swarm
+[docker-btn]: ../images/deployBtns/docker.png
+[docker-deploy]: ../docker/HOW_TO_USE_DOCKER.md
+[aws-btn]: ../images/deployBtns/aws.png
+[aws-deploy]: ../cloud-deployments/aws/cloudformation/DEPLOY.md
+[gcp-btn]: https://deploy.cloud.run/button.svg
+[gcp-deploy]: ../cloud-deployments/gcp/deployment/DEPLOY.md
+[do-btn]: https://www.deploytodo.com/do-btn-blue.svg
+[do-deploy]: ../cloud-deployments/digitalocean/terraform/DEPLOY.md
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[render-btn]: https://render.com/images/deploy-to-render-button.svg
+[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render
+[railway-btn]: https://railway.app/button.svg
+[railway-deploy]: https://railway.app/template/HNSCS1?referralCode=WFgJkn
+[repocloud-btn]: https://d16t0pc4846x52.cloudfront.net/deploylobe.svg
+[repocloud-deploy]: https://repocloud.io/details/?app_id=276
+[elestio-btn]: https://elest.io/images/logos/deploy-to-elestio-btn.png
+[elestio-deploy]: https://elest.io/open-source/anythingllm
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..d8b45cbea281624d9f0716720830cab5b65fb3e1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "anything-llm",
+ "version": "1.8.5",
+ "description": "The best solution for turning private documents into a chat bot using off-the-shelf tools and commercially viable AI technologies.",
+ "main": "index.js",
+ "type": "module",
+ "author": "Timothy Carambat (Mintplex Labs)",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "scripts": {
+ "test": "jest",
+ "lint": "cd server && yarn lint && cd ../frontend && yarn lint && cd ../collector && yarn lint",
+ "setup": "cd server && yarn && cd ../collector && yarn && cd ../frontend && yarn && cd .. && yarn setup:envs && yarn prisma:setup && echo \"Please run yarn dev:server, yarn dev:collector, and yarn dev:frontend in separate terminal tabs.\"",
+ "setup:envs": "cp -n ./frontend/.env.example ./frontend/.env && cp -n ./server/.env.example ./server/.env.development && cp -n ./collector/.env.example ./collector/.env && cp -n ./docker/.env.example ./docker/.env && echo \"All ENV files copied!\n\"",
+ "dev:server": "cd server && yarn dev",
+ "dev:collector": "cd collector && yarn dev",
+ "dev:frontend": "cd frontend && yarn dev",
+ "dev:all": "npx concurrently \"yarn dev:server\" \"yarn dev:frontend\" \"yarn dev:collector\"",
+ "prisma:generate": "cd server && npx prisma generate",
+ "prisma:migrate": "cd server && npx prisma migrate dev --name init",
+ "prisma:seed": "cd server && npx prisma db seed",
+ "prisma:setup": "yarn prisma:generate && yarn prisma:migrate && yarn prisma:seed",
+ "prisma:reset": "truncate -s 0 server/storage/anythingllm.db && yarn prisma:migrate",
+ "prod:server": "cd server && yarn start",
+ "prod:frontend": "cd frontend && yarn build",
+ "generate:cloudformation": "node cloud-deployments/aws/cloudformation/generate.mjs",
+ "generate::gcp_deployment": "node cloud-deployments/gcp/deployment/generate.mjs",
+ "verify:translations": "cd frontend/src/locales && node verifyTranslations.mjs",
+ "normalize:translations": "cd frontend/src/locales && node normalizeEn.mjs && cd ../../.. && yarn lint && yarn verify:translations"
+ },
+ "private": false,
+ "devDependencies": {
+ "concurrently": "^9.1.2",
+ "jest": "^29.7.0"
+ }
+}
\ No newline at end of file
diff --git a/pull_request_template.md b/pull_request_template.md
new file mode 100644
index 0000000000000000000000000000000000000000..fc609ba19d509c248e74394b602729104e54683d
--- /dev/null
+++ b/pull_request_template.md
@@ -0,0 +1,36 @@
+
+ ### Pull Request Type
+
+
+
+- [ ] ✨ feat
+- [ ] 🐛 fix
+- [ ] ♻️ refactor
+- [ ] 💄 style
+- [ ] 🔨 chore
+- [ ] 📝 docs
+
+### Relevant Issues
+
+
+
+resolves #xxx
+
+
+### What is in this change?
+
+
+
+
+### Additional Information
+
+
+
+### Developer Validations
+
+
+
+- [ ] I ran `yarn lint` from the root of the repo & committed changes
+- [ ] Relevant documentation has been updated
+- [ ] I have tested my code functionality
+- [ ] Docker build succeeds locally
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..c60319ab6ab3eab83a0f3f1ae7ef1af4e7aa270d
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,370 @@
+SERVER_PORT=3001
+JWT_SECRET="my-random-string-for-seeding" # Please generate random string at least 12 chars long.
+# JWT_EXPIRY="30d" # (optional) https://docs.anythingllm.com/configuration#custom-ttl-for-sessions
+SIG_KEY='passphrase' # Please generate random string at least 32 chars long.
+SIG_SALT='salt' # Please generate random string at least 32 chars long.
+
+###########################################
+######## LLM API SElECTION ################
+###########################################
+# LLM_PROVIDER='openai'
+# OPEN_AI_KEY=
+# OPEN_MODEL_PREF='gpt-4o'
+
+# LLM_PROVIDER='gemini'
+# GEMINI_API_KEY=
+# GEMINI_LLM_MODEL_PREF='gemini-2.0-flash-lite'
+
+# LLM_PROVIDER='azure'
+# AZURE_OPENAI_ENDPOINT=
+# AZURE_OPENAI_KEY=
+# OPEN_MODEL_PREF='my-gpt35-deployment' # This is the "deployment" on Azure you want to use. Not the base model.
+# EMBEDDING_MODEL_PREF='embedder-model' # This is the "deployment" on Azure you want to use for embeddings. Not the base model. Valid base model is text-embedding-ada-002
+
+# LLM_PROVIDER='anthropic'
+# ANTHROPIC_API_KEY=sk-ant-xxxx
+# ANTHROPIC_MODEL_PREF='claude-2'
+
+# LLM_PROVIDER='lmstudio'
+# LMSTUDIO_BASE_PATH='http://your-server:1234/v1'
+# LMSTUDIO_MODEL_PREF='Loaded from Chat UI' # this is a bug in LMStudio 0.2.17
+# LMSTUDIO_MODEL_TOKEN_LIMIT=4096
+
+# LLM_PROVIDER='localai'
+# LOCAL_AI_BASE_PATH='http://localhost:8080/v1'
+# LOCAL_AI_MODEL_PREF='luna-ai-llama2'
+# LOCAL_AI_MODEL_TOKEN_LIMIT=4096
+# LOCAL_AI_API_KEY="sk-123abc"
+
+# LLM_PROVIDER='ollama'
+# OLLAMA_BASE_PATH='http://host.docker.internal:11434'
+# OLLAMA_MODEL_PREF='llama2'
+# OLLAMA_MODEL_TOKEN_LIMIT=4096
+# OLLAMA_AUTH_TOKEN='your-ollama-auth-token-here (optional, only for ollama running behind auth - Bearer token)'
+
+# LLM_PROVIDER='togetherai'
+# TOGETHER_AI_API_KEY='my-together-ai-key'
+# TOGETHER_AI_MODEL_PREF='mistralai/Mixtral-8x7B-Instruct-v0.1'
+
+# LLM_PROVIDER='fireworksai'
+# FIREWORKS_AI_LLM_API_KEY='my-fireworks-ai-key'
+# FIREWORKS_AI_LLM_MODEL_PREF='accounts/fireworks/models/llama-v3p1-8b-instruct'
+
+# LLM_PROVIDER='perplexity'
+# PERPLEXITY_API_KEY='my-perplexity-key'
+# PERPLEXITY_MODEL_PREF='codellama-34b-instruct'
+
+# LLM_PROVIDER='deepseek'
+# DEEPSEEK_API_KEY=YOUR_API_KEY
+# DEEPSEEK_MODEL_PREF='deepseek-chat'
+
+# LLM_PROVIDER='openrouter'
+# OPENROUTER_API_KEY='my-openrouter-key'
+# OPENROUTER_MODEL_PREF='openrouter/auto'
+
+# LLM_PROVIDER='mistral'
+# MISTRAL_API_KEY='example-mistral-ai-api-key'
+# MISTRAL_MODEL_PREF='mistral-tiny'
+
+# LLM_PROVIDER='huggingface'
+# HUGGING_FACE_LLM_ENDPOINT=https://uuid-here.us-east-1.aws.endpoints.huggingface.cloud
+# HUGGING_FACE_LLM_API_KEY=hf_xxxxxx
+# HUGGING_FACE_LLM_TOKEN_LIMIT=8000
+
+# LLM_PROVIDER='groq'
+# GROQ_API_KEY=gsk_abcxyz
+# GROQ_MODEL_PREF=llama3-8b-8192
+
+# LLM_PROVIDER='koboldcpp'
+# KOBOLD_CPP_BASE_PATH='http://127.0.0.1:5000/v1'
+# KOBOLD_CPP_MODEL_PREF='koboldcpp/codellama-7b-instruct.Q4_K_S'
+# KOBOLD_CPP_MODEL_TOKEN_LIMIT=4096
+# KOBOLD_CPP_MAX_TOKENS=2048
+
+# LLM_PROVIDER='textgenwebui'
+# TEXT_GEN_WEB_UI_BASE_PATH='http://127.0.0.1:5000/v1'
+# TEXT_GEN_WEB_UI_TOKEN_LIMIT=4096
+# TEXT_GEN_WEB_UI_API_KEY='sk-123abc'
+
+# LLM_PROVIDER='generic-openai'
+# GENERIC_OPEN_AI_BASE_PATH='http://proxy.url.openai.com/v1'
+# GENERIC_OPEN_AI_MODEL_PREF='gpt-3.5-turbo'
+# GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT=4096
+# GENERIC_OPEN_AI_API_KEY=sk-123abc
+
+# LLM_PROVIDER='litellm'
+# LITE_LLM_MODEL_PREF='gpt-3.5-turbo'
+# LITE_LLM_MODEL_TOKEN_LIMIT=4096
+# LITE_LLM_BASE_PATH='http://127.0.0.1:4000'
+# LITE_LLM_API_KEY='sk-123abc'
+
+# LLM_PROVIDER='novita'
+# NOVITA_LLM_API_KEY='your-novita-api-key-here' check on https://novita.ai/settings#key-management
+# NOVITA_LLM_MODEL_PREF='deepseek/deepseek-r1'
+
+# LLM_PROVIDER='cohere'
+# COHERE_API_KEY=
+# COHERE_MODEL_PREF='command-r'
+
+# LLM_PROVIDER='cometapi'
+# COMETAPI_LLM_API_KEY='your-cometapi-key-here' # Get one at https://api.cometapi.com/console/token
+# COMETAPI_LLM_MODEL_PREF='gpt-5-mini'
+# COMETAPI_LLM_TIMEOUT_MS=500 # Optional; stream idle timeout in ms (min 500ms)
+
+
+# LLM_PROVIDER='bedrock'
+# AWS_BEDROCK_LLM_ACCESS_KEY_ID=
+# AWS_BEDROCK_LLM_ACCESS_KEY=
+# AWS_BEDROCK_LLM_REGION=us-west-2
+# AWS_BEDROCK_LLM_MODEL_PREFERENCE=meta.llama3-1-8b-instruct-v1:0
+# AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT=8191
+# AWS_BEDROCK_LLM_CONNECTION_METHOD=iam
+# AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS=4096
+# AWS_BEDROCK_LLM_SESSION_TOKEN= # Only required if CONNECTION_METHOD is 'sessionToken'
+
+# LLM_PROVIDER='apipie'
+# APIPIE_LLM_API_KEY='sk-123abc'
+# APIPIE_LLM_MODEL_PREF='openrouter/llama-3.1-8b-instruct'
+
+# LLM_PROVIDER='xai'
+# XAI_LLM_API_KEY='xai-your-api-key-here'
+# XAI_LLM_MODEL_PREF='grok-beta'
+
+# LLM_PROVIDER='nvidia-nim'
+# NVIDIA_NIM_LLM_BASE_PATH='http://127.0.0.1:8000'
+# NVIDIA_NIM_LLM_MODEL_PREF='meta/llama-3.2-3b-instruct'
+
+# LLM_PROVIDER='ppio'
+# PPIO_API_KEY='your-ppio-api-key-here'
+# PPIO_MODEL_PREF='deepseek/deepseek-v3/community'
+
+# LLM_PROVIDER='moonshotai'
+# MOONSHOT_AI_API_KEY='your-moonshot-api-key-here'
+# MOONSHOT_AI_MODEL_PREF='moonshot-v1-32k'
+
+###########################################
+######## Embedding API SElECTION ##########
+###########################################
+# This will be the assumed default embedding seleciton and model
+# EMBEDDING_ENGINE='native'
+# EMBEDDING_MODEL_PREF='Xenova/all-MiniLM-L6-v2'
+
+# Only used if you are using an LLM that does not natively support embedding (openai or Azure)
+# EMBEDDING_ENGINE='openai'
+# OPEN_AI_KEY=sk-xxxx
+# EMBEDDING_MODEL_PREF='text-embedding-ada-002'
+
+# EMBEDDING_ENGINE='azure'
+# AZURE_OPENAI_ENDPOINT=
+# AZURE_OPENAI_KEY=
+# EMBEDDING_MODEL_PREF='my-embedder-model' # This is the "deployment" on Azure you want to use for embeddings. Not the base model. Valid base model is text-embedding-ada-002
+
+# EMBEDDING_ENGINE='localai'
+# EMBEDDING_BASE_PATH='http://localhost:8080/v1'
+# EMBEDDING_MODEL_PREF='text-embedding-ada-002'
+# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=1000 # The max chunk size in chars a string to embed can be
+
+# EMBEDDING_ENGINE='ollama'
+# EMBEDDING_BASE_PATH='http://127.0.0.1:11434'
+# EMBEDDING_MODEL_PREF='nomic-embed-text:latest'
+# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192
+
+# EMBEDDING_ENGINE='lmstudio'
+# EMBEDDING_BASE_PATH='https://localhost:1234/v1'
+# EMBEDDING_MODEL_PREF='nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.Q4_0.gguf'
+# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192
+
+# EMBEDDING_ENGINE='cohere'
+# COHERE_API_KEY=
+# EMBEDDING_MODEL_PREF='embed-english-v3.0'
+
+# EMBEDDING_ENGINE='voyageai'
+# VOYAGEAI_API_KEY=
+# EMBEDDING_MODEL_PREF='voyage-large-2-instruct'
+
+# EMBEDDING_ENGINE='litellm'
+# EMBEDDING_MODEL_PREF='text-embedding-ada-002'
+# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192
+# LITE_LLM_BASE_PATH='http://127.0.0.1:4000'
+# LITE_LLM_API_KEY='sk-123abc'
+
+# EMBEDDING_ENGINE='generic-openai'
+# EMBEDDING_MODEL_PREF='text-embedding-ada-002'
+# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192
+# EMBEDDING_BASE_PATH='http://127.0.0.1:4000'
+# GENERIC_OPEN_AI_EMBEDDING_API_KEY='sk-123abc'
+# GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS=500
+# GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS=1000
+
+# EMBEDDING_ENGINE='gemini'
+# GEMINI_EMBEDDING_API_KEY=
+# EMBEDDING_MODEL_PREF='text-embedding-004'
+
+###########################################
+######## Vector Database Selection ########
+###########################################
+# Enable all below if you are using vector database: Chroma.
+# VECTOR_DB="chroma"
+# CHROMA_ENDPOINT='http://localhost:8000'
+# CHROMA_API_HEADER="X-Api-Key"
+# CHROMA_API_KEY="sk-123abc"
+
+# Enable all below if you are using vector database: Chroma Cloud.
+# VECTOR_DB="chromacloud"
+# CHROMACLOUD_API_KEY="ck-your-api-key"
+# CHROMACLOUD_TENANT=
+# CHROMACLOUD_DATABASE=
+
+# Enable all below if you are using vector database: Pinecone.
+# VECTOR_DB="pinecone"
+# PINECONE_API_KEY=
+# PINECONE_INDEX=
+
+# Enable all below if you are using vector database: Astra DB.
+# VECTOR_DB="astra"
+# ASTRA_DB_APPLICATION_TOKEN=
+# ASTRA_DB_ENDPOINT=
+
+# Enable all below if you are using vector database: LanceDB.
+VECTOR_DB="lancedb"
+
+# Enable all below if you are using vector database: Weaviate.
+# VECTOR_DB="pgvector"
+# PGVECTOR_CONNECTION_STRING="postgresql://dbuser:dbuserpass@localhost:5432/yourdb"
+# PGVECTOR_TABLE_NAME="anythingllm_vectors" # optional, but can be defined
+
+# Enable all below if you are using vector database: Weaviate.
+# VECTOR_DB="weaviate"
+# WEAVIATE_ENDPOINT="http://localhost:8080"
+# WEAVIATE_API_KEY=
+
+# Enable all below if you are using vector database: Qdrant.
+# VECTOR_DB="qdrant"
+# QDRANT_ENDPOINT="http://localhost:6333"
+# QDRANT_API_KEY=
+
+# Enable all below if you are using vector database: Milvus.
+# VECTOR_DB="milvus"
+# MILVUS_ADDRESS="http://localhost:19530"
+# MILVUS_USERNAME=
+# MILVUS_PASSWORD=
+
+# Enable all below if you are using vector database: Zilliz Cloud.
+# VECTOR_DB="zilliz"
+# ZILLIZ_ENDPOINT="https://sample.api.gcp-us-west1.zillizcloud.com"
+# ZILLIZ_API_TOKEN=api-token-here
+
+###########################################
+######## Audio Model Selection ############
+###########################################
+# (default) use built-in whisper-small model.
+WHISPER_PROVIDER="local"
+
+# use openai hosted whisper model.
+# WHISPER_PROVIDER="openai"
+# OPEN_AI_KEY=sk-xxxxxxxx
+
+###########################################
+######## TTS/STT Model Selection ##########
+###########################################
+TTS_PROVIDER="native"
+
+# TTS_PROVIDER="openai"
+# TTS_OPEN_AI_KEY=sk-example
+# TTS_OPEN_AI_VOICE_MODEL=nova
+
+# TTS_PROVIDER="elevenlabs"
+# TTS_ELEVEN_LABS_KEY=
+# TTS_ELEVEN_LABS_VOICE_MODEL=21m00Tcm4TlvDq8ikWAM # Rachel
+
+# TTS_PROVIDER="generic-openai"
+# TTS_OPEN_AI_COMPATIBLE_KEY=sk-example
+# TTS_OPEN_AI_COMPATIBLE_MODEL=tts-1
+# TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL=nova
+# TTS_OPEN_AI_COMPATIBLE_ENDPOINT="https://api.openai.com/v1"
+
+# CLOUD DEPLOYMENT VARIRABLES ONLY
+# AUTH_TOKEN="hunter2" # This is the password to your application if remote hosting.
+# STORAGE_DIR= # absolute filesystem path with no trailing slash
+
+###########################################
+######## PASSWORD COMPLEXITY ##############
+###########################################
+# Enforce a password schema for your organization users.
+# Documentation on how to use https://github.com/kamronbatman/joi-password-complexity
+#PASSWORDMINCHAR=8
+#PASSWORDMAXCHAR=250
+#PASSWORDLOWERCASE=1
+#PASSWORDUPPERCASE=1
+#PASSWORDNUMERIC=1
+#PASSWORDSYMBOL=1
+#PASSWORDREQUIREMENTS=4
+
+###########################################
+######## ENABLE HTTPS SERVER ##############
+###########################################
+# By enabling this and providing the path/filename for the key and cert,
+# the server will use HTTPS instead of HTTP.
+#ENABLE_HTTPS="true"
+#HTTPS_CERT_PATH="sslcert/cert.pem"
+#HTTPS_KEY_PATH="sslcert/key.pem"
+
+###########################################
+######## AGENT SERVICE KEYS ###############
+###########################################
+
+#------ SEARCH ENGINES -------
+#=============================
+#------ Google Search -------- https://programmablesearchengine.google.com/controlpanel/create
+# AGENT_GSE_KEY=
+# AGENT_GSE_CTX=
+
+#------ SearchApi.io ----------- https://www.searchapi.io/
+# AGENT_SEARCHAPI_API_KEY=
+# AGENT_SEARCHAPI_ENGINE=google
+
+#------ Serper.dev ----------- https://serper.dev/
+# AGENT_SERPER_DEV_KEY=
+
+#------ Bing Search ----------- https://portal.azure.com/
+# AGENT_BING_SEARCH_API_KEY=
+
+#------ Serply.io ----------- https://serply.io/
+# AGENT_SERPLY_API_KEY=
+
+#------ SearXNG ----------- https://github.com/searxng/searxng
+# AGENT_SEARXNG_API_URL=
+
+#------ Tavily ----------- https://www.tavily.com/
+# AGENT_TAVILY_API_KEY=
+
+#------ Exa Search ----------- https://www.exa.ai/
+# AGENT_EXA_API_KEY=
+
+###########################################
+######## Other Configurations ############
+###########################################
+
+# Disable viewing chat history from the UI and frontend APIs.
+# See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information.
+# DISABLE_VIEW_CHAT_HISTORY=1
+
+# Enable simple SSO passthrough to pre-authenticate users from a third party service.
+# See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information.
+# SIMPLE_SSO_ENABLED=1
+# SIMPLE_SSO_NO_LOGIN=1
+# SIMPLE_SSO_NO_LOGIN_REDIRECT=https://your-custom-login-url.com (optional)
+
+# Allow scraping of any IP address in collector - must be string "true" to be enabled
+# See https://docs.anythingllm.com/configuration#local-ip-address-scraping for more information.
+# COLLECTOR_ALLOW_ANY_IP="true"
+
+# Specify the target languages for when using OCR to parse images and PDFs.
+# This is a comma separated list of language codes as a string. Unsupported languages will be ignored.
+# Default is English. See https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html for a list of valid language codes.
+# TARGET_OCR_LANG=eng,deu,ita,spa,fra,por,rus,nld,tur,hun,pol,ita,spa,fra,por,rus,nld,tur,hun,pol
+
+# Runtime flags for built-in pupeeteer Chromium instance
+# This is only required on Linux machines running AnythingLLM via Docker
+# and do not want to use the --cap-add=SYS_ADMIN docker argument
+# ANYTHINGLLM_CHROMIUM_ARGS="--no-sandbox,--disable-setuid-sandbox"
\ No newline at end of file
diff --git a/server/.flowconfig b/server/.flowconfig
new file mode 100644
index 0000000000000000000000000000000000000000..866bdc0dde328ae0bdf8f9ce65d81dd040ffd729
--- /dev/null
+++ b/server/.flowconfig
@@ -0,0 +1,30 @@
+# How to config: https://flow.org/en/docs/config/
+[version]
+
+[options]
+all=false
+emoji=false
+include_warnings=false
+lazy_mode=false
+
+[include]
+
+[ignore]
+.*/node_modules/resolve/test/.*
+
+[untyped]
+# /src/models/.*
+
+[declarations]
+
+[libs]
+
+[lints]
+all=warn
+
+[strict]
+nonstrict-import
+unclear-type
+unsafe-getters-setters
+untyped-import
+untyped-type-import
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..7f8b3a5c527bb05e2298e6bab85da49a39ec9435
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,30 @@
+.env.production
+.env.development
+.env.test
+storage/assets/*
+!storage/assets/anything-llm.png
+storage/documents/*
+storage/comkey/*
+storage/tmp/*
+storage/vector-cache/*.json
+storage/exports
+storage/imports
+storage/plugins/agent-skills/*
+storage/plugins/agent-flows/*
+storage/plugins/office-extensions/*
+storage/plugins/anythingllm_mcp_servers.json
+!storage/documents/DOCUMENTS.md
+storage/direct-uploads
+logs/server.log
+*.db
+*.db-journal
+storage/lancedb
+public/
+
+# For legacy copies of repo
+documents
+vector-cache
+yarn-error.log
+
+# Local SSL Certs for HTTPS
+sslcert
\ No newline at end of file
diff --git a/server/.nvmrc b/server/.nvmrc
new file mode 100644
index 0000000000000000000000000000000000000000..59f4a2f3ab843c5f1b4767ef0eae1d72a7597393
--- /dev/null
+++ b/server/.nvmrc
@@ -0,0 +1 @@
+v18.13.0
\ No newline at end of file
diff --git a/server/__tests__/utils/SQLConnectors/connectionParser.test.js b/server/__tests__/utils/SQLConnectors/connectionParser.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..b984391ddf05f64130eb0f5e835b3247224f0384
--- /dev/null
+++ b/server/__tests__/utils/SQLConnectors/connectionParser.test.js
@@ -0,0 +1,178 @@
+/* eslint-env jest */
+const { ConnectionStringParser } = require("../../../utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils");
+
+describe("ConnectionStringParser", () => {
+ describe("Basic Parsing", () => {
+ test("should parse a basic connection string without options", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user:pass@localhost:1433/mydb");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user",
+ password: "pass",
+ hosts: [{ host: "localhost", port: 1433 }],
+ endpoint: "mydb",
+ options: undefined
+ });
+ });
+
+ test("should parse a connection string with options", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user:pass@localhost:1433/mydb?encrypt=true&trustServerCertificate=true");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user",
+ password: "pass",
+ hosts: [{ host: "localhost", port: 1433 }],
+ endpoint: "mydb",
+ options: {
+ encrypt: "true",
+ trustServerCertificate: "true"
+ }
+ });
+ });
+
+ test("should handle empty passwords", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user@localhost:1433/mydb");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user",
+ password: undefined,
+ hosts: [{ host: "localhost", port: 1433 }],
+ endpoint: "mydb",
+ options: undefined
+ });
+ });
+ });
+
+ describe("Error Handling", () => {
+ test("should throw error for invalid scheme", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ expect(() => parser.parse("mysql://user:pass@localhost:3306/mydb"))
+ .toThrow("URI must start with 'mssql://'");
+ });
+
+ test("should throw error for missing scheme", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ expect(() => parser.parse("user:pass@localhost:1433/mydb"))
+ .toThrow("No scheme found in URI");
+ });
+ });
+
+ describe("Special Characters", () => {
+ test("should handle special characters in username and password", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user%40domain:p%40ssw%3Ard@localhost:1433/mydb");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user@domain",
+ password: "p@ssw:rd",
+ hosts: [{ host: "localhost", port: 1433 }],
+ endpoint: "mydb",
+ options: undefined
+ });
+ });
+
+ test("should handle special characters in database name", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user:pass@localhost:1433/my%20db");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user",
+ password: "pass",
+ hosts: [{ host: "localhost", port: 1433 }],
+ endpoint: "my db",
+ options: undefined
+ });
+ });
+ });
+
+ describe("Multiple Hosts", () => {
+ test("should parse multiple hosts", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user:pass@host1:1433,host2:1434/mydb");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user",
+ password: "pass",
+ hosts: [
+ { host: "host1", port: 1433 },
+ { host: "host2", port: 1434 }
+ ],
+ endpoint: "mydb",
+ options: undefined
+ });
+ });
+
+ test("should handle hosts without ports", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user:pass@host1,host2/mydb");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user",
+ password: "pass",
+ hosts: [
+ { host: "host1" },
+ { host: "host2" }
+ ],
+ endpoint: "mydb",
+ options: undefined
+ });
+ });
+ });
+
+ describe("Provider-Specific Tests", () => {
+ test("should parse MySQL connection string", () => {
+ const parser = new ConnectionStringParser({ scheme: "mysql" });
+ const result = parser.parse("mysql://user:pass@localhost:3306/mydb?ssl=true");
+
+ expect(result).toEqual({
+ scheme: "mysql",
+ username: "user",
+ password: "pass",
+ hosts: [{ host: "localhost", port: 3306 }],
+ endpoint: "mydb",
+ options: { ssl: "true" }
+ });
+ });
+
+ test("should parse PostgreSQL connection string", () => {
+ const parser = new ConnectionStringParser({ scheme: "postgresql" });
+ const result = parser.parse("postgresql://user:pass@localhost:5432/mydb?sslmode=require");
+
+ expect(result).toEqual({
+ scheme: "postgresql",
+ username: "user",
+ password: "pass",
+ hosts: [{ host: "localhost", port: 5432 }],
+ endpoint: "mydb",
+ options: { sslmode: "require" }
+ });
+ });
+
+ test("should parse MSSQL connection string with encryption options", () => {
+ const parser = new ConnectionStringParser({ scheme: "mssql" });
+ const result = parser.parse("mssql://user:pass@localhost:1433/mydb?encrypt=true&trustServerCertificate=true");
+
+ expect(result).toEqual({
+ scheme: "mssql",
+ username: "user",
+ password: "pass",
+ hosts: [{ host: "localhost", port: 1433 }],
+ endpoint: "mydb",
+ options: {
+ encrypt: "true",
+ trustServerCertificate: "true"
+ }
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/utils/TextSplitter/index.test.js b/server/__tests__/utils/TextSplitter/index.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..8ca0160c1a71e002f6684efd49ee2b5fe324bac7
--- /dev/null
+++ b/server/__tests__/utils/TextSplitter/index.test.js
@@ -0,0 +1,104 @@
+const { TextSplitter } = require("../../../utils/TextSplitter");
+const _ = require("lodash");
+
+describe("TextSplitter", () => {
+ test("should split long text into n sized chunks", async () => {
+ const text = "This is a test text to be split into chunks".repeat(2);
+ const textSplitter = new TextSplitter({
+ chunkSize: 20,
+ chunkOverlap: 0,
+ });
+ const chunks = await textSplitter.splitText(text);
+ expect(chunks.length).toEqual(5);
+ });
+
+ test("applies chunk overlap of 20 characters on invalid chunkOverlap", async () => {
+ const text = "This is a test text to be split into chunks".repeat(2);
+ const textSplitter = new TextSplitter({
+ chunkSize: 30,
+ });
+ const chunks = await textSplitter.splitText(text);
+ expect(chunks.length).toEqual(6);
+ });
+
+ test("does not allow chunkOverlap to be greater than chunkSize", async () => {
+ expect(() => {
+ new TextSplitter({
+ chunkSize: 20,
+ chunkOverlap: 21,
+ });
+ }).toThrow();
+ });
+
+ test("applies specific metadata to stringifyHeader to each chunk", async () => {
+ const metadata = {
+ id: "123e4567-e89b-12d3-a456-426614174000",
+ url: "https://example.com",
+ title: "Example",
+ docAuthor: "John Doe",
+ published: "2021-01-01",
+ chunkSource: "link://https://example.com",
+ description: "This is a test text to be split into chunks",
+ };
+ const chunkHeaderMeta = TextSplitter.buildHeaderMeta(metadata);
+ expect(chunkHeaderMeta).toEqual({
+ sourceDocument: metadata.title,
+ source: metadata.url,
+ published: metadata.published,
+ });
+ });
+
+ test("applies a valid chunkPrefix to each chunk", async () => {
+ const text = "This is a test text to be split into chunks".repeat(2);
+ let textSplitter = new TextSplitter({
+ chunkSize: 20,
+ chunkOverlap: 0,
+ chunkPrefix: "testing: ",
+ });
+ let chunks = await textSplitter.splitText(text);
+ expect(chunks.length).toEqual(5);
+ expect(chunks.every(chunk => chunk.startsWith("testing: "))).toBe(true);
+
+ textSplitter = new TextSplitter({
+ chunkSize: 20,
+ chunkOverlap: 0,
+ chunkPrefix: "testing2: ",
+ });
+ chunks = await textSplitter.splitText(text);
+ expect(chunks.length).toEqual(5);
+ expect(chunks.every(chunk => chunk.startsWith("testing2: "))).toBe(true);
+
+ textSplitter = new TextSplitter({
+ chunkSize: 20,
+ chunkOverlap: 0,
+ chunkPrefix: undefined,
+ });
+ chunks = await textSplitter.splitText(text);
+ expect(chunks.length).toEqual(5);
+ expect(chunks.every(chunk => !chunk.startsWith(": "))).toBe(true);
+
+ textSplitter = new TextSplitter({
+ chunkSize: 20,
+ chunkOverlap: 0,
+ chunkPrefix: "",
+ });
+ chunks = await textSplitter.splitText(text);
+ expect(chunks.length).toEqual(5);
+ expect(chunks.every(chunk => !chunk.startsWith(": "))).toBe(true);
+
+ // Applied chunkPrefix with chunkHeaderMeta
+ textSplitter = new TextSplitter({
+ chunkSize: 20,
+ chunkOverlap: 0,
+ chunkHeaderMeta: TextSplitter.buildHeaderMeta({
+ title: "Example",
+ url: "https://example.com",
+ published: "2021-01-01",
+ }),
+ chunkPrefix: "testing3: ",
+ });
+ chunks = await textSplitter.splitText(text);
+ expect(chunks.length).toEqual(5);
+ expect(chunks.every(chunk => chunk.startsWith("testing3: "))).toBe(true);
+ });
+});
diff --git a/server/__tests__/utils/agentFlows/executor.test.js b/server/__tests__/utils/agentFlows/executor.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..e62c8b42db0698c0dd66a5a266a93fd67e94d3f2
--- /dev/null
+++ b/server/__tests__/utils/agentFlows/executor.test.js
@@ -0,0 +1,93 @@
+const { FlowExecutor } = require("../../../utils/agentFlows/executor");
+
+describe("FlowExecutor: getValueFromPath", () => {
+ const executor = new FlowExecutor();
+
+ it("can handle invalid objects", () => {
+ expect(executor.getValueFromPath(null, "a.b.c")).toBe("");
+ expect(executor.getValueFromPath(undefined, "a.b.c")).toBe("");
+ expect(executor.getValueFromPath(1, "a.b.c")).toBe("");
+ expect(executor.getValueFromPath("string", "a.b.c")).toBe("");
+ expect(executor.getValueFromPath(true, "a.b.c")).toBe("");
+ });
+
+ it("can handle invalid paths", () => {
+ const obj = { a: { b: { c: "answer" } } };
+ expect(executor.getValueFromPath(obj, -1)).toBe("");
+ expect(executor.getValueFromPath(obj, undefined)).toBe("");
+ expect(executor.getValueFromPath(obj, [1, 2, 3])).toBe("");
+ expect(executor.getValueFromPath(obj, () => { })).toBe("");
+ });
+
+ it("should be able to resolve a value from a dot path at various levels", () => {
+ let obj = {
+ a: {
+ prop: "top-prop",
+ b: {
+ c: "answer",
+ num: 100,
+ arr: [1, 2, 3],
+ subarr: [
+ { id: 1, name: "answer2" },
+ { id: 2, name: "answer3" },
+ { id: 3, name: "answer4" },
+ ]
+ }
+ }
+ };
+ expect(executor.getValueFromPath(obj, "a.prop")).toBe("top-prop");
+ expect(executor.getValueFromPath(obj, "a.b.c")).toBe("answer");
+ expect(executor.getValueFromPath(obj, "a.b.num")).toBe(100);
+ expect(executor.getValueFromPath(obj, "a.b.arr[0]")).toBe(1);
+ expect(executor.getValueFromPath(obj, "a.b.arr[1]")).toBe(2);
+ expect(executor.getValueFromPath(obj, "a.b.arr[2]")).toBe(3);
+ expect(executor.getValueFromPath(obj, "a.b.subarr[0].id")).toBe(1);
+ expect(executor.getValueFromPath(obj, "a.b.subarr[0].name")).toBe("answer2");
+ expect(executor.getValueFromPath(obj, "a.b.subarr[1].id")).toBe(2);
+ expect(executor.getValueFromPath(obj, "a.b.subarr[2].name")).toBe("answer4");
+ expect(executor.getValueFromPath(obj, "a.b.subarr[2].id")).toBe(3);
+ });
+
+ it("should return empty string if the path is invalid", () => {
+ const result = executor.getValueFromPath({}, "a.b.c");
+ expect(result).toBe("");
+ });
+
+ it("should return empty string if the object is invalid", () => {
+ const result = executor.getValueFromPath(null, "a.b.c");
+ expect(result).toBe("");
+ });
+
+ it("can return a stringified item if the path target is not an object or array", () => {
+ const obj = { a: { b: { c: "answer", numbers: [1, 2, 3] } } };
+ expect(executor.getValueFromPath(obj, "a.b")).toEqual(JSON.stringify(obj.a.b));
+ expect(executor.getValueFromPath(obj, "a.b.numbers")).toEqual(JSON.stringify(obj.a.b.numbers));
+ expect(executor.getValueFromPath(obj, "a.b.c")).toBe("answer");
+ });
+
+ it("can return a stringified object if the path target is an array", () => {
+ const obj = { a: { b: [1, 2, 3] } };
+ expect(executor.getValueFromPath(obj, "a.b")).toEqual(JSON.stringify(obj.a.b));
+ expect(executor.getValueFromPath(obj, "a.b[0]")).toBe(1);
+ expect(executor.getValueFromPath(obj, "a.b[1]")).toBe(2);
+ expect(executor.getValueFromPath(obj, "a.b[2]")).toBe(3);
+ });
+
+ it("can find a value by string key traversal", () => {
+ const obj = {
+ a: {
+ items: [
+ {
+ 'my-long-key': [
+ { id: 1, name: "answer1" },
+ { id: 2, name: "answer2" },
+ { id: 3, name: "answer3" },
+ ]
+ },
+ ],
+ }
+ };
+ expect(executor.getValueFromPath(obj, "a.items[0]['my-long-key'][1].id")).toBe(2);
+ expect(executor.getValueFromPath(obj, "a.items[0]['my-long-key'][1].name")).toBe("answer2");
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/utils/agents/aibitat/providers/helpers/untooled.test.js b/server/__tests__/utils/agents/aibitat/providers/helpers/untooled.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..a3364f902d3e6ca72380cbe5070b87854803b736
--- /dev/null
+++ b/server/__tests__/utils/agents/aibitat/providers/helpers/untooled.test.js
@@ -0,0 +1,87 @@
+const UnTooled = require("../../../../../../utils/agents/aibitat/providers/helpers/untooled");
+
+describe("UnTooled: validFuncCall", () => {
+ const untooled = new UnTooled();
+ const validFunc = {
+ "name": "brave-search-brave_web_search",
+ "description": "Example function",
+ "parameters": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search query (max 400 chars, 50 words)"
+ },
+ "count": {
+ "type": "number",
+ "description": "Number of results (1-20, default 10)",
+ "default": 10
+ },
+ "offset": {
+ "type": "number",
+ "description": "Pagination offset (max 9, default 0)",
+ "default": 0
+ }
+ },
+ "required": [
+ "query"
+ ]
+ }
+ };
+
+ it("Be truthy if the function call is valid and has all required arguments", () => {
+ const result = untooled.validFuncCall(
+ {
+ name: validFunc.name,
+ arguments: { query: "test" },
+ }, [validFunc]);
+ expect(result.valid).toBe(true);
+ expect(result.reason).toBe(null);
+ });
+
+ it("Be falsey if the function call has no name or arguments", () => {
+ const result = untooled.validFuncCall(
+ { arguments: {} }, [validFunc]);
+ expect(result.valid).toBe(false);
+ expect(result.reason).toBe("Missing name or arguments in function call.");
+
+ const result2 = untooled.validFuncCall(
+ { name: validFunc.name }, [validFunc]);
+ expect(result2.valid).toBe(false);
+ expect(result2.reason).toBe("Missing name or arguments in function call.");
+ });
+
+ it("Be falsey if the function call references an unknown function definition", () => {
+ const result = untooled.validFuncCall(
+ {
+ name: "unknown-function",
+ arguments: {},
+ }, [validFunc]);
+ expect(result.valid).toBe(false);
+ expect(result.reason).toBe("Function name does not exist.");
+ });
+
+ it("Be falsey if the function call is valid but missing any required arguments", () => {
+ const result = untooled.validFuncCall(
+ {
+ name: validFunc.name,
+ arguments: {},
+ }, [validFunc]);
+ expect(result.valid).toBe(false);
+ expect(result.reason).toBe("Missing required argument: query");
+ });
+
+ it("Be falsey if the function call is valid but has an unknown argument defined (required or not)", () => {
+ const result = untooled.validFuncCall(
+ {
+ name: validFunc.name,
+ arguments: {
+ query: "test",
+ unknown: "unknown",
+ },
+ }, [validFunc]);
+ expect(result.valid).toBe(false);
+ expect(result.reason).toBe("Unknown argument: unknown provided but not in schema.");
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/utils/chats/openaiCompatible.test.js b/server/__tests__/utils/chats/openaiCompatible.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..d17fcb0496b428a56e36cda99cb7e8278291aa37
--- /dev/null
+++ b/server/__tests__/utils/chats/openaiCompatible.test.js
@@ -0,0 +1,249 @@
+/* eslint-env jest, node */
+const { OpenAICompatibleChat } = require('../../../utils/chats/openaiCompatible');
+const { WorkspaceChats } = require('../../../models/workspaceChats');
+const { getVectorDbClass, getLLMProvider } = require('../../../utils/helpers');
+const { extractTextContent, extractAttachments } = require('../../../endpoints/api/openai/helpers');
+
+// Mock dependencies
+jest.mock('../../../models/workspaceChats');
+jest.mock('../../../utils/helpers');
+jest.mock('../../../utils/DocumentManager', () => ({
+ DocumentManager: class {
+ constructor() {
+ this.pinnedDocs = jest.fn().mockResolvedValue([]);
+ }
+ }
+}));
+
+describe('OpenAICompatibleChat', () => {
+ let mockWorkspace;
+ let mockVectorDb;
+ let mockLLMConnector;
+ let mockResponse;
+
+ beforeEach(() => {
+ // Reset all mocks
+ jest.clearAllMocks();
+
+ // Setup mock workspace
+ mockWorkspace = {
+ id: 1,
+ slug: 'test-workspace',
+ chatMode: 'chat',
+ chatProvider: 'openai',
+ chatModel: 'gpt-4',
+ };
+
+ // Setup mock VectorDb
+ mockVectorDb = {
+ hasNamespace: jest.fn().mockResolvedValue(true),
+ namespaceCount: jest.fn().mockResolvedValue(1),
+ performSimilaritySearch: jest.fn().mockResolvedValue({
+ contextTexts: [],
+ sources: [],
+ message: null,
+ }),
+ };
+ getVectorDbClass.mockReturnValue(mockVectorDb);
+
+ // Setup mock LLM connector
+ mockLLMConnector = {
+ promptWindowLimit: jest.fn().mockReturnValue(4000),
+ compressMessages: jest.fn().mockResolvedValue([]),
+ getChatCompletion: jest.fn().mockResolvedValue({
+ textResponse: 'Mock response',
+ metrics: {},
+ }),
+ streamingEnabled: jest.fn().mockReturnValue(true),
+ streamGetChatCompletion: jest.fn().mockResolvedValue({
+ metrics: {},
+ }),
+ handleStream: jest.fn().mockResolvedValue('Mock streamed response'),
+ defaultTemp: 0.7,
+ };
+ getLLMProvider.mockReturnValue(mockLLMConnector);
+
+ // Setup WorkspaceChats mock
+ WorkspaceChats.new.mockResolvedValue({ chat: { id: 'mock-chat-id' } });
+
+ // Setup mock response object for streaming
+ mockResponse = {
+ write: jest.fn(),
+ };
+ });
+
+ describe('chatSync', () => {
+ test('should handle OpenAI vision multimodal messages', async () => {
+ const multiModalPrompt = [
+ {
+ type: 'text',
+ text: 'What do you see in this image?'
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,abc123',
+ detail: 'low'
+ }
+ }
+ ];
+
+ const prompt = extractTextContent(multiModalPrompt);
+ const attachments = extractAttachments(multiModalPrompt);
+ const result = await OpenAICompatibleChat.chatSync({
+ workspace: mockWorkspace,
+ prompt,
+ attachments,
+ systemPrompt: 'You are a helpful assistant',
+ history: [
+ { role: 'user', content: 'Previous message' },
+ { role: 'assistant', content: 'Previous response' }
+ ],
+ temperature: 0.7
+ });
+
+ // Verify chat was saved with correct format
+ expect(WorkspaceChats.new).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: mockWorkspace.id,
+ prompt: multiModalPrompt[0].text,
+ response: expect.objectContaining({
+ text: 'Mock response',
+ attachments: [{
+ name: 'uploaded_image_0',
+ mime: 'image/png',
+ contentString: multiModalPrompt[1].image_url.url
+ }]
+ })
+ })
+ );
+
+ // Verify response format
+ expect(result).toEqual(
+ expect.objectContaining({
+ object: 'chat.completion',
+ choices: expect.arrayContaining([
+ expect.objectContaining({
+ message: expect.objectContaining({
+ role: 'assistant',
+ content: 'Mock response',
+ }),
+ }),
+ ]),
+ })
+ );
+ });
+
+ test('should handle regular text messages in OpenAI format', async () => {
+ const promptString = 'Hello world';
+ const result = await OpenAICompatibleChat.chatSync({
+ workspace: mockWorkspace,
+ prompt: promptString,
+ systemPrompt: 'You are a helpful assistant',
+ history: [
+ { role: 'user', content: 'Previous message' },
+ { role: 'assistant', content: 'Previous response' }
+ ],
+ temperature: 0.7
+ });
+
+ // Verify chat was saved without attachments
+ expect(WorkspaceChats.new).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: mockWorkspace.id,
+ prompt: promptString,
+ response: expect.objectContaining({
+ text: 'Mock response',
+ attachments: []
+ })
+ })
+ );
+
+ expect(result).toBeTruthy();
+ });
+ });
+
+ describe('streamChat', () => {
+ test('should handle OpenAI vision multimodal messages in streaming mode', async () => {
+ const multiModalPrompt = [
+ {
+ type: 'text',
+ text: 'What do you see in this image?'
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,abc123',
+ detail: 'low'
+ }
+ }
+ ];
+
+ const prompt = extractTextContent(multiModalPrompt);
+ const attachments = extractAttachments(multiModalPrompt);
+ await OpenAICompatibleChat.streamChat({
+ workspace: mockWorkspace,
+ response: mockResponse,
+ prompt,
+ attachments,
+ systemPrompt: 'You are a helpful assistant',
+ history: [
+ { role: 'user', content: 'Previous message' },
+ { role: 'assistant', content: 'Previous response' }
+ ],
+ temperature: 0.7
+ });
+
+ // Verify streaming was handled
+ expect(mockLLMConnector.streamGetChatCompletion).toHaveBeenCalled();
+ expect(mockLLMConnector.handleStream).toHaveBeenCalled();
+
+ // Verify chat was saved with attachments
+ expect(WorkspaceChats.new).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: mockWorkspace.id,
+ prompt: multiModalPrompt[0].text,
+ response: expect.objectContaining({
+ text: 'Mock streamed response',
+ attachments: [{
+ name: 'uploaded_image_0',
+ mime: 'image/png',
+ contentString: multiModalPrompt[1].image_url.url
+ }]
+ })
+ })
+ );
+ });
+
+ test('should handle regular text messages in streaming mode', async () => {
+ const promptString = 'Hello world';
+ await OpenAICompatibleChat.streamChat({
+ workspace: mockWorkspace,
+ response: mockResponse,
+ prompt: promptString,
+ systemPrompt: 'You are a helpful assistant',
+ history: [
+ { role: 'user', content: 'Previous message' },
+ { role: 'assistant', content: 'Previous response' }
+ ],
+ temperature: 0.7
+ });
+
+ // Verify streaming was handled
+ expect(mockLLMConnector.streamGetChatCompletion).toHaveBeenCalled();
+ expect(mockLLMConnector.handleStream).toHaveBeenCalled();
+
+ // Verify chat was saved without attachments
+ expect(WorkspaceChats.new).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: mockWorkspace.id,
+ prompt: promptString,
+ response: expect.objectContaining({
+ text: 'Mock streamed response',
+ attachments: []
+ })
+ })
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/utils/chats/openaiHelpers.test.js b/server/__tests__/utils/chats/openaiHelpers.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..5eba2e11264abb1d516ae121e40a9ade8a43cf6a
--- /dev/null
+++ b/server/__tests__/utils/chats/openaiHelpers.test.js
@@ -0,0 +1,128 @@
+/* eslint-env jest, node */
+const { extractTextContent, extractAttachments } = require('../../../endpoints/api/openai/helpers');
+
+describe('OpenAI Helper Functions', () => {
+ describe('extractTextContent', () => {
+ test('should return string content as-is when not an array', () => {
+ const content = 'Hello world';
+ expect(extractTextContent(content)).toBe('Hello world');
+ });
+
+ test('should extract text from multi-modal content array', () => {
+ const content = [
+ {
+ type: 'text',
+ text: 'What do you see in this image?'
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,abc123',
+ detail: 'low'
+ }
+ },
+ {
+ type: 'text',
+ text: 'And what about this part?'
+ }
+ ];
+ expect(extractTextContent(content)).toBe('What do you see in this image?\nAnd what about this part?');
+ });
+
+ test('should handle empty array', () => {
+ expect(extractTextContent([])).toBe('');
+ });
+
+ test('should handle array with no text content', () => {
+ const content = [
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,abc123',
+ detail: 'low'
+ }
+ }
+ ];
+ expect(extractTextContent(content)).toBe('');
+ });
+ });
+
+ describe('extractAttachments', () => {
+ test('should return empty array for string content', () => {
+ const content = 'Hello world';
+ expect(extractAttachments(content)).toEqual([]);
+ });
+
+ test('should extract image attachments with correct mime types', () => {
+ const content = [
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,abc123',
+ detail: 'low'
+ }
+ },
+ {
+ type: 'text',
+ text: 'Between images'
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/jpeg;base64,def456',
+ detail: 'high'
+ }
+ }
+ ];
+ expect(extractAttachments(content)).toEqual([
+ {
+ name: 'uploaded_image_0',
+ mime: 'image/png',
+ contentString: 'data:image/png;base64,abc123'
+ },
+ {
+ name: 'uploaded_image_1',
+ mime: 'image/jpeg',
+ contentString: 'data:image/jpeg;base64,def456'
+ }
+ ]);
+ });
+
+ test('should handle invalid data URLs with PNG fallback', () => {
+ const content = [
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'invalid-data-url',
+ detail: 'low'
+ }
+ }
+ ];
+ expect(extractAttachments(content)).toEqual([
+ {
+ name: 'uploaded_image_0',
+ mime: 'image/png',
+ contentString: 'invalid-data-url'
+ }
+ ]);
+ });
+
+ test('should handle empty array', () => {
+ expect(extractAttachments([])).toEqual([]);
+ });
+
+ test('should handle array with no image content', () => {
+ const content = [
+ {
+ type: 'text',
+ text: 'Just some text'
+ },
+ {
+ type: 'text',
+ text: 'More text'
+ }
+ ];
+ expect(extractAttachments(content)).toEqual([]);
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/utils/helpers/convertTo.test.js b/server/__tests__/utils/helpers/convertTo.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..2b974d87ed7188d27d1e506ab9f72d3d9f5aaf07
--- /dev/null
+++ b/server/__tests__/utils/helpers/convertTo.test.js
@@ -0,0 +1,238 @@
+/* eslint-env jest */
+const { prepareChatsForExport } = require("../../../utils/helpers/chat/convertTo");
+
+// Mock the database models
+jest.mock("../../../models/workspaceChats");
+jest.mock("../../../models/embedChats");
+
+const { WorkspaceChats } = require("../../../models/workspaceChats");
+const { EmbedChats } = require("../../../models/embedChats");
+
+const mockChat = (withImages = false) => {
+ return {
+ id: 1,
+ prompt: "Test prompt",
+ response: JSON.stringify({
+ text: "Test response",
+ attachments: withImages ? [
+ { mime: "image/png", name: "image.png", contentString: "data:image/png;base64,iVBORw0KGg....=" },
+ { mime: "image/jpeg", name: "image2.jpeg", contentString: "data:image/jpeg;base64,iVBORw0KGg....=" }
+ ] : [],
+ sources: [],
+ metrics: {},
+ }),
+ createdAt: new Date(),
+ workspace: { name: "Test Workspace", openAiPrompt: "Test OpenAI Prompt" },
+ user: { username: "testuser" },
+ feedbackScore: 1,
+ }
+};
+
+describe("prepareChatsForExport", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ WorkspaceChats.whereWithData = jest.fn().mockResolvedValue([]);
+ EmbedChats.whereWithEmbedAndWorkspace = jest.fn().mockResolvedValue([]);
+ });
+
+ test("should throw error for invalid chat type", async () => {
+ await expect(prepareChatsForExport("json", "invalid"))
+ .rejects
+ .toThrow("Invalid chat type: invalid");
+ });
+
+ test("should throw error for invalid export type", async () => {
+ await expect(prepareChatsForExport("invalid", "workspace"))
+ .rejects
+ .toThrow("Invalid export type: invalid");
+ });
+
+ // CSV and JSON are the same format, so we can test them together
+ test("should return prepared data in csv and json format for workspace chat type", async () => {
+ const chatExample = mockChat();
+ WorkspaceChats.whereWithData.mockResolvedValue([chatExample]);
+ const result = await prepareChatsForExport("json", "workspace");
+
+ const responseJson = JSON.parse(chatExample.response);
+ expect(result).toBeDefined();
+ expect(result).toEqual([{
+ id: chatExample.id,
+ prompt: chatExample.prompt,
+ response: responseJson.text,
+ sent_at: chatExample.createdAt,
+ rating: chatExample.feedbackScore ? "GOOD" : "BAD",
+ username: chatExample.user.username,
+ workspace: chatExample.workspace.name,
+ attachments: [],
+ }]);
+ });
+
+ test("Should handle attachments for workspace chat type when json format is selected", async () => {
+ const chatExample = mockChat(true);
+ WorkspaceChats.whereWithData.mockResolvedValue([chatExample]);
+ const result = await prepareChatsForExport("json", "workspace");
+
+ const responseJson = JSON.parse(chatExample.response);
+ expect(result).toBeDefined();
+ expect(result).toEqual([{
+ id: chatExample.id,
+ prompt: chatExample.prompt,
+ response: responseJson.text,
+ sent_at: chatExample.createdAt,
+ rating: chatExample.feedbackScore ? "GOOD" : "BAD",
+ username: chatExample.user.username,
+ workspace: chatExample.workspace.name,
+ attachments: [
+ {
+ type: "image",
+ image: responseJson.attachments[0].contentString,
+ },
+ {
+ type: "image",
+ image: responseJson.attachments[1].contentString,
+ },
+ ]
+ }]);
+ });
+
+ test("Should ignore attachments for workspace chat type when csv format is selected", async () => {
+ const chatExample = mockChat(true);
+ WorkspaceChats.whereWithData.mockResolvedValue([chatExample]);
+ const result = await prepareChatsForExport("csv", "workspace");
+
+ const responseJson = JSON.parse(chatExample.response);
+ expect(result).toBeDefined();
+ expect(result.attachments).not.toBeDefined();
+ expect(result).toEqual([{
+ id: chatExample.id,
+ prompt: chatExample.prompt,
+ response: responseJson.text,
+ sent_at: chatExample.createdAt,
+ rating: chatExample.feedbackScore ? "GOOD" : "BAD",
+ username: chatExample.user.username,
+ workspace: chatExample.workspace.name,
+ }]);
+ });
+
+ test("should return prepared data in jsonAlpaca format for workspace chat type", async () => {
+ const chatExample = mockChat();
+ const imageChatExample = mockChat(true);
+ WorkspaceChats.whereWithData.mockResolvedValue([chatExample, imageChatExample]);
+ const result = await prepareChatsForExport("jsonAlpaca", "workspace");
+
+ const responseJson1 = JSON.parse(chatExample.response);
+ const responseJson2 = JSON.parse(imageChatExample.response);
+ expect(result).toBeDefined();
+
+ // Alpaca format does not support attachments - so they are not included
+ expect(result[0].attachments).not.toBeDefined();
+ expect(result[1].attachments).not.toBeDefined();
+ expect(result).toEqual([{
+ instruction: chatExample.workspace.openAiPrompt,
+ input: chatExample.prompt,
+ output: responseJson1.text,
+ },
+ {
+ instruction: chatExample.workspace.openAiPrompt,
+ input: imageChatExample.prompt,
+ output: responseJson2.text,
+ }]);
+ });
+
+ test("should return prepared data in jsonl format for workspace chat type", async () => {
+ const chatExample = mockChat();
+ const responseJson = JSON.parse(chatExample.response);
+ WorkspaceChats.whereWithData.mockResolvedValue([chatExample]);
+ const result = await prepareChatsForExport("jsonl", "workspace");
+ expect(result).toBeDefined();
+ expect(result).toEqual(
+ {
+ [chatExample.workspace.id]: {
+ messages: [
+ {
+ role: "system",
+ content: [{
+ type: "text",
+ text: chatExample.workspace.openAiPrompt,
+ }],
+ },
+ {
+ role: "user",
+ content: [{
+ type: "text",
+ text: chatExample.prompt,
+ }],
+ },
+ {
+ role: "assistant",
+ content: [{
+ type: "text",
+ text: responseJson.text,
+ }],
+ },
+ ],
+ },
+ },
+ );
+ });
+
+ test("should return prepared data in jsonl format for workspace chat type with attachments", async () => {
+ const chatExample = mockChat();
+ const imageChatExample = mockChat(true);
+ const responseJson = JSON.parse(chatExample.response);
+ const imageResponseJson = JSON.parse(imageChatExample.response);
+
+ WorkspaceChats.whereWithData.mockResolvedValue([chatExample, imageChatExample]);
+ const result = await prepareChatsForExport("jsonl", "workspace");
+ expect(result).toBeDefined();
+ expect(result).toEqual(
+ {
+ [chatExample.workspace.id]: {
+ messages: [
+ {
+ role: "system",
+ content: [{
+ type: "text",
+ text: chatExample.workspace.openAiPrompt,
+ }],
+ },
+ {
+ role: "user",
+ content: [{
+ type: "text",
+ text: chatExample.prompt,
+ }],
+ },
+ {
+ role: "assistant",
+ content: [{
+ type: "text",
+ text: responseJson.text,
+ }],
+ },
+ {
+ role: "user",
+ content: [{
+ type: "text",
+ text: imageChatExample.prompt,
+ }, {
+ type: "image",
+ image: imageResponseJson.attachments[0].contentString,
+ }, {
+ type: "image",
+ image: imageResponseJson.attachments[1].contentString,
+ }],
+ },
+ {
+ role: "assistant",
+ content: [{
+ type: "text",
+ text: imageResponseJson.text,
+ }],
+ },
+ ],
+ },
+ },
+ );
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/utils/safeJSONStringify/safeJSONStringify.test.js b/server/__tests__/utils/safeJSONStringify/safeJSONStringify.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..2165698035f044b28fcc0624be1302bd2260c7f0
--- /dev/null
+++ b/server/__tests__/utils/safeJSONStringify/safeJSONStringify.test.js
@@ -0,0 +1,60 @@
+/* eslint-env jest */
+const { safeJSONStringify } = require("../../../utils/helpers/chat/responses");
+
+describe("safeJSONStringify", () => {
+ test("handles regular objects without BigInt", () => {
+ const obj = { a: 1, b: "test", c: true, d: null };
+ expect(safeJSONStringify(obj)).toBe(JSON.stringify(obj));
+ });
+
+ test("converts BigInt to string", () => {
+ const bigInt = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1);
+ expect(safeJSONStringify(bigInt)).toBe(`"${bigInt.toString()}"`);
+ });
+
+ test("handles nested BigInt values", () => {
+ const obj = {
+ metrics: {
+ tokens: BigInt(123),
+ nested: { moreBigInt: BigInt(456) }
+ },
+ normal: "value"
+ };
+ expect(safeJSONStringify(obj)).toBe(
+ '{"metrics":{"tokens":"123","nested":{"moreBigInt":"456"}},"normal":"value"}'
+ );
+ });
+
+ test("handles arrays with BigInt", () => {
+ const arr = [BigInt(1), 2, BigInt(3)];
+ expect(safeJSONStringify(arr)).toBe('["1",2,"3"]');
+ });
+
+ test("handles mixed complex objects", () => {
+ const obj = {
+ id: 1,
+ bigNums: [BigInt(123), BigInt(456)],
+ nested: {
+ more: { huge: BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1) }
+ },
+ normal: { str: "test", num: 42, bool: true, nil: null, sub_arr: ["alpha", "beta", "gamma", 1, 2, BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1), { map: { a: BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1) } }] }
+ };
+ const result = JSON.parse(safeJSONStringify(obj)); // Should parse back without errors
+ expect(typeof result.bigNums[0]).toBe("string");
+ expect(result.bigNums[0]).toEqual("123");
+ expect(typeof result.nested.more.huge).toBe("string");
+ expect(result.normal).toEqual({ str: "test", num: 42, bool: true, nil: null, sub_arr: ["alpha", "beta", "gamma", 1, 2, (BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString(), { map: { a: (BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString() } }] });
+ expect(result.normal.sub_arr[6].map.a).toEqual((BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString());
+ });
+
+ test("handles invariants", () => {
+ expect(safeJSONStringify({})).toBe("{}");
+ expect(safeJSONStringify(null)).toBe("null");
+ expect(safeJSONStringify(undefined)).toBe(undefined);
+ expect(safeJSONStringify(true)).toBe("true");
+ expect(safeJSONStringify(false)).toBe("false");
+ expect(safeJSONStringify(0)).toBe("0");
+ expect(safeJSONStringify(1)).toBe("1");
+ expect(safeJSONStringify(-1)).toBe("-1");
+ });
+});
\ No newline at end of file
diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js
new file mode 100644
index 0000000000000000000000000000000000000000..4964c35a13355b9df60869fe73fb1d0f1f7beb8d
--- /dev/null
+++ b/server/endpoints/admin.js
@@ -0,0 +1,562 @@
+const { ApiKey } = require("../models/apiKeys");
+const { Document } = require("../models/documents");
+const { EventLogs } = require("../models/eventLogs");
+const { Invite } = require("../models/invite");
+const { SystemSettings } = require("../models/systemSettings");
+const { Telemetry } = require("../models/telemetry");
+const { User } = require("../models/user");
+const { DocumentVectors } = require("../models/vectors");
+const { Workspace } = require("../models/workspace");
+const { WorkspaceChats } = require("../models/workspaceChats");
+const {
+ getVectorDbClass,
+ getEmbeddingEngineSelection,
+} = require("../utils/helpers");
+const {
+ validRoleSelection,
+ canModifyAdmin,
+ validCanModify,
+} = require("../utils/helpers/admin");
+const { reqBody, userFromSession, safeJsonParse } = require("../utils/http");
+const {
+ strictMultiUserRoleValid,
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const ImportedPlugin = require("../utils/agents/imported");
+const {
+ simpleSSOLoginDisabledMiddleware,
+} = require("../utils/middleware/simpleSSOEnabled");
+
+function adminEndpoints(app) {
+ if (!app) return;
+
+ app.get(
+ "/admin/users",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (_request, response) => {
+ try {
+ const users = await User.where();
+ response.status(200).json({ users });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/users/new",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const currUser = await userFromSession(request, response);
+ const newUserParams = reqBody(request);
+ const roleValidation = validRoleSelection(currUser, newUserParams);
+
+ if (!roleValidation.valid) {
+ response
+ .status(200)
+ .json({ user: null, error: roleValidation.error });
+ return;
+ }
+
+ const { user: newUser, error } = await User.create(newUserParams);
+ if (!!newUser) {
+ await EventLogs.logEvent(
+ "user_created",
+ {
+ userName: newUser.username,
+ createdBy: currUser.username,
+ },
+ currUser.id
+ );
+ }
+
+ response.status(200).json({ user: newUser, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/user/:id",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const currUser = await userFromSession(request, response);
+ const { id } = request.params;
+ const updates = reqBody(request);
+ const user = await User.get({ id: Number(id) });
+
+ const canModify = validCanModify(currUser, user);
+ if (!canModify.valid) {
+ response.status(200).json({ success: false, error: canModify.error });
+ return;
+ }
+
+ const roleValidation = validRoleSelection(currUser, updates);
+ if (!roleValidation.valid) {
+ response
+ .status(200)
+ .json({ success: false, error: roleValidation.error });
+ return;
+ }
+
+ const validAdminRoleModification = await canModifyAdmin(user, updates);
+ if (!validAdminRoleModification.valid) {
+ response
+ .status(200)
+ .json({ success: false, error: validAdminRoleModification.error });
+ return;
+ }
+
+ const { success, error } = await User.update(id, updates);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/admin/user/:id",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const currUser = await userFromSession(request, response);
+ const { id } = request.params;
+ const user = await User.get({ id: Number(id) });
+
+ const canModify = validCanModify(currUser, user);
+ if (!canModify.valid) {
+ response.status(200).json({ success: false, error: canModify.error });
+ return;
+ }
+
+ await User.delete({ id: Number(id) });
+ await EventLogs.logEvent(
+ "user_deleted",
+ {
+ userName: user.username,
+ deletedBy: currUser.username,
+ },
+ currUser.id
+ );
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/admin/invites",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (_request, response) => {
+ try {
+ const invites = await Invite.whereWithUsers();
+ response.status(200).json({ invites });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/invite/new",
+ [
+ validatedRequest,
+ strictMultiUserRoleValid([ROLES.admin, ROLES.manager]),
+ simpleSSOLoginDisabledMiddleware,
+ ],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const body = reqBody(request);
+ const { invite, error } = await Invite.create({
+ createdByUserId: user.id,
+ workspaceIds: body?.workspaceIds || [],
+ });
+
+ await EventLogs.logEvent(
+ "invite_created",
+ {
+ inviteCode: invite.code,
+ createdBy: response.locals?.user?.username,
+ },
+ response.locals?.user?.id
+ );
+ response.status(200).json({ invite, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/admin/invite/:id",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ const { success, error } = await Invite.deactivate(id);
+ await EventLogs.logEvent(
+ "invite_deleted",
+ { deletedBy: response.locals?.user?.username },
+ response.locals?.user?.id
+ );
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/admin/workspaces",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (_request, response) => {
+ try {
+ const workspaces = await Workspace.whereWithUsers();
+ response.status(200).json({ workspaces });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/admin/workspaces/:workspaceId/users",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { workspaceId } = request.params;
+ const users = await Workspace.workspaceUsers(workspaceId);
+ response.status(200).json({ users });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/workspaces/new",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { name } = reqBody(request);
+ const { workspace, message: error } = await Workspace.new(
+ name,
+ user.id
+ );
+ response.status(200).json({ workspace, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/workspaces/:workspaceId/update-users",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { workspaceId } = request.params;
+ const { userIds } = reqBody(request);
+ const { success, error } = await Workspace.updateUsers(
+ workspaceId,
+ userIds
+ );
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/admin/workspaces/:id",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ const VectorDb = getVectorDbClass();
+ const workspace = await Workspace.get({ id: Number(id) });
+ if (!workspace) {
+ response.sendStatus(404).end();
+ return;
+ }
+
+ await WorkspaceChats.delete({ workspaceId: Number(workspace.id) });
+ await DocumentVectors.deleteForWorkspace(Number(workspace.id));
+ await Document.delete({ workspaceId: Number(workspace.id) });
+ await Workspace.delete({ id: Number(workspace.id) });
+ try {
+ await VectorDb["delete-namespace"]({ namespace: workspace.slug });
+ } catch (e) {
+ console.error(e.message);
+ }
+
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ // System preferences but only by array of labels
+ app.get(
+ "/admin/system-preferences-for",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const requestedSettings = {};
+ const labels = request.query.labels?.split(",") || [];
+ const needEmbedder = [
+ "text_splitter_chunk_size",
+ "max_embed_chunk_size",
+ ];
+ const noRecord = [
+ "max_embed_chunk_size",
+ "agent_sql_connections",
+ "imported_agent_skills",
+ "feature_flags",
+ "meta_page_title",
+ "meta_page_favicon",
+ ];
+
+ for (const label of labels) {
+ // Skip any settings that are not explicitly defined as public
+ if (!SystemSettings.publicFields.includes(label)) continue;
+
+ // Only get the embedder if the setting actually needs it
+ let embedder = needEmbedder.includes(label)
+ ? getEmbeddingEngineSelection()
+ : null;
+ // Only get the record from db if the setting actually needs it
+ let setting = noRecord.includes(label)
+ ? null
+ : await SystemSettings.get({ label });
+
+ switch (label) {
+ case "footer_data":
+ requestedSettings[label] = setting?.value ?? JSON.stringify([]);
+ break;
+ case "support_email":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "text_splitter_chunk_size":
+ requestedSettings[label] =
+ setting?.value || embedder?.embeddingMaxChunkLength || null;
+ break;
+ case "text_splitter_chunk_overlap":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "max_embed_chunk_size":
+ requestedSettings[label] =
+ embedder?.embeddingMaxChunkLength || 1000;
+ break;
+ case "agent_search_provider":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "agent_sql_connections":
+ requestedSettings[label] =
+ await SystemSettings.brief.agent_sql_connections();
+ break;
+ case "default_agent_skills":
+ requestedSettings[label] = safeJsonParse(setting?.value, []);
+ break;
+ case "disabled_agent_skills":
+ requestedSettings[label] = safeJsonParse(setting?.value, []);
+ break;
+ case "imported_agent_skills":
+ requestedSettings[label] = ImportedPlugin.listImportedPlugins();
+ break;
+ case "custom_app_name":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "feature_flags":
+ requestedSettings[label] =
+ (await SystemSettings.getFeatureFlags()) || {};
+ break;
+ case "meta_page_title":
+ requestedSettings[label] =
+ await SystemSettings.getValueOrFallback({ label }, null);
+ break;
+ case "meta_page_favicon":
+ requestedSettings[label] =
+ await SystemSettings.getValueOrFallback({ label }, null);
+ break;
+ default:
+ break;
+ }
+ }
+
+ response.status(200).json({ settings: requestedSettings });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ // TODO: Delete this endpoint
+ // DEPRECATED - use /admin/system-preferences-for instead with ?labels=... comma separated string of labels
+ app.get(
+ "/admin/system-preferences",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (_, response) => {
+ try {
+ const embedder = getEmbeddingEngineSelection();
+ const settings = {
+ footer_data:
+ (await SystemSettings.get({ label: "footer_data" }))?.value ||
+ JSON.stringify([]),
+ support_email:
+ (await SystemSettings.get({ label: "support_email" }))?.value ||
+ null,
+ text_splitter_chunk_size:
+ (await SystemSettings.get({ label: "text_splitter_chunk_size" }))
+ ?.value ||
+ embedder?.embeddingMaxChunkLength ||
+ null,
+ text_splitter_chunk_overlap:
+ (await SystemSettings.get({ label: "text_splitter_chunk_overlap" }))
+ ?.value || null,
+ max_embed_chunk_size: embedder?.embeddingMaxChunkLength || 1000,
+ agent_search_provider:
+ (await SystemSettings.get({ label: "agent_search_provider" }))
+ ?.value || null,
+ agent_sql_connections:
+ await SystemSettings.brief.agent_sql_connections(),
+ default_agent_skills:
+ safeJsonParse(
+ (await SystemSettings.get({ label: "default_agent_skills" }))
+ ?.value,
+ []
+ ) || [],
+ disabled_agent_skills:
+ safeJsonParse(
+ (await SystemSettings.get({ label: "disabled_agent_skills" }))
+ ?.value,
+ []
+ ) || [],
+ imported_agent_skills: ImportedPlugin.listImportedPlugins(),
+ custom_app_name:
+ (await SystemSettings.get({ label: "custom_app_name" }))?.value ||
+ null,
+ feature_flags: (await SystemSettings.getFeatureFlags()) || {},
+ meta_page_title: await SystemSettings.getValueOrFallback(
+ { label: "meta_page_title" },
+ null
+ ),
+ meta_page_favicon: await SystemSettings.getValueOrFallback(
+ { label: "meta_page_favicon" },
+ null
+ ),
+ };
+ response.status(200).json({ settings });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/admin/system-preferences",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const updates = reqBody(request);
+ await SystemSettings.updateSettings(updates);
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/admin/api-keys",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin])],
+ async (_request, response) => {
+ try {
+ const apiKeys = await ApiKey.whereWithUser({});
+ return response.status(200).json({
+ apiKeys,
+ error: null,
+ });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({
+ apiKey: null,
+ error: "Could not find an API Keys.",
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/admin/generate-api-key",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { apiKey, error } = await ApiKey.create(user.id);
+ await EventLogs.logEvent(
+ "api_key_created",
+ { createdBy: user?.username },
+ user?.id
+ );
+ return response.status(200).json({
+ apiKey,
+ error,
+ });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/admin/delete-api-key/:id",
+ [validatedRequest, strictMultiUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ if (!id || isNaN(Number(id))) return response.sendStatus(400).end();
+ await ApiKey.delete({ id: Number(id) });
+
+ await EventLogs.logEvent(
+ "api_key_deleted",
+ { deletedBy: response.locals?.user?.username },
+ response?.locals?.user?.id
+ );
+ return response.status(200).end();
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { adminEndpoints };
diff --git a/server/endpoints/agentFlows.js b/server/endpoints/agentFlows.js
new file mode 100644
index 0000000000000000000000000000000000000000..0f3c164accb5475907b6d0e8c60922c6a2f2c40b
--- /dev/null
+++ b/server/endpoints/agentFlows.js
@@ -0,0 +1,200 @@
+const { AgentFlows } = require("../utils/agentFlows");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { Telemetry } = require("../models/telemetry");
+
+function agentFlowEndpoints(app) {
+ if (!app) return;
+
+ // Save a flow configuration
+ app.post(
+ "/agent-flows/save",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { name, config, uuid } = request.body;
+
+ if (!name || !config) {
+ return response.status(400).json({
+ success: false,
+ error: "Name and config are required",
+ });
+ }
+
+ const flow = AgentFlows.saveFlow(name, config, uuid);
+ if (!flow || !flow.success)
+ return response
+ .status(200)
+ .json({ flow: null, error: flow.error || "Failed to save flow" });
+
+ if (!uuid) {
+ await Telemetry.sendTelemetry("agent_flow_created", {
+ blockCount: config.blocks?.length || 0,
+ });
+ }
+
+ return response.status(200).json({
+ success: true,
+ flow,
+ });
+ } catch (error) {
+ console.error("Error saving flow:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ // List all available flows
+ app.get(
+ "/agent-flows/list",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_request, response) => {
+ try {
+ const flows = AgentFlows.listFlows();
+ return response.status(200).json({
+ success: true,
+ flows,
+ });
+ } catch (error) {
+ console.error("Error listing flows:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ // Get a specific flow by UUID
+ app.get(
+ "/agent-flows/:uuid",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { uuid } = request.params;
+ const flow = AgentFlows.loadFlow(uuid);
+ if (!flow) {
+ return response.status(404).json({
+ success: false,
+ error: "Flow not found",
+ });
+ }
+
+ return response.status(200).json({
+ success: true,
+ flow,
+ });
+ } catch (error) {
+ console.error("Error getting flow:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ // Run a specific flow
+ // app.post(
+ // "/agent-flows/:uuid/run",
+ // [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ // async (request, response) => {
+ // try {
+ // const { uuid } = request.params;
+ // const { variables = {} } = request.body;
+
+ // // TODO: Implement flow execution
+ // console.log("Running flow with UUID:", uuid);
+
+ // await Telemetry.sendTelemetry("agent_flow_executed", {
+ // variableCount: Object.keys(variables).length,
+ // });
+
+ // return response.status(200).json({
+ // success: true,
+ // results: {
+ // success: true,
+ // results: "test",
+ // variables: variables,
+ // },
+ // });
+ // } catch (error) {
+ // console.error("Error running flow:", error);
+ // return response.status(500).json({
+ // success: false,
+ // error: error.message,
+ // });
+ // }
+ // }
+ // );
+
+ // Delete a specific flow
+ app.delete(
+ "/agent-flows/:uuid",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { uuid } = request.params;
+ const { success } = AgentFlows.deleteFlow(uuid);
+
+ if (!success) {
+ return response.status(500).json({
+ success: false,
+ error: "Failed to delete flow",
+ });
+ }
+
+ return response.status(200).json({
+ success,
+ });
+ } catch (error) {
+ console.error("Error deleting flow:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ // Toggle flow active status
+ app.post(
+ "/agent-flows/:uuid/toggle",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { uuid } = request.params;
+ const { active } = request.body;
+
+ const flow = AgentFlows.loadFlow(uuid);
+ if (!flow) {
+ return response
+ .status(404)
+ .json({ success: false, error: "Flow not found" });
+ }
+
+ flow.config.active = active;
+ const { success } = AgentFlows.saveFlow(flow.name, flow.config, uuid);
+
+ if (!success) {
+ return response
+ .status(500)
+ .json({ success: false, error: "Failed to update flow" });
+ }
+
+ return response.json({ success: true, flow });
+ } catch (error) {
+ console.error("Error toggling flow:", error);
+ response.status(500).json({ success: false, error: error.message });
+ }
+ }
+ );
+}
+
+module.exports = { agentFlowEndpoints };
diff --git a/server/endpoints/agentWebsocket.js b/server/endpoints/agentWebsocket.js
new file mode 100644
index 0000000000000000000000000000000000000000..c5fc1475fb7969b73a24695f9ac070ac20bcac29
--- /dev/null
+++ b/server/endpoints/agentWebsocket.js
@@ -0,0 +1,61 @@
+const { Telemetry } = require("../models/telemetry");
+const {
+ WorkspaceAgentInvocation,
+} = require("../models/workspaceAgentInvocation");
+const { AgentHandler } = require("../utils/agents");
+const {
+ WEBSOCKET_BAIL_COMMANDS,
+} = require("../utils/agents/aibitat/plugins/websocket");
+const { safeJsonParse } = require("../utils/http");
+
+// Setup listener for incoming messages to relay to socket so it can be handled by agent plugin.
+function relayToSocket(message) {
+ if (this.handleFeedback) return this?.handleFeedback?.(message);
+ this.checkBailCommand(message);
+}
+
+function agentWebsocket(app) {
+ if (!app) return;
+
+ app.ws("/agent-invocation/:uuid", async function (socket, request) {
+ try {
+ const agentHandler = await new AgentHandler({
+ uuid: String(request.params.uuid),
+ }).init();
+
+ if (!agentHandler.invocation) {
+ socket.close();
+ return;
+ }
+
+ socket.on("message", relayToSocket);
+ socket.on("close", () => {
+ agentHandler.closeAlert();
+ WorkspaceAgentInvocation.close(String(request.params.uuid));
+ return;
+ });
+
+ socket.checkBailCommand = (data) => {
+ const content = safeJsonParse(data)?.feedback;
+ if (WEBSOCKET_BAIL_COMMANDS.includes(content)) {
+ agentHandler.log(
+ `User invoked bail command while processing. Closing session now.`
+ );
+ agentHandler.aibitat.abort();
+ socket.close();
+ return;
+ }
+ };
+
+ await Telemetry.sendTelemetry("agent_chat_started");
+ await agentHandler.createAIbitat({ socket });
+ await agentHandler.startAgentCluster();
+ } catch (e) {
+ console.error(e.message, e);
+ socket?.send(JSON.stringify({ type: "wssFailure", content: e.message }));
+ socket?.close();
+ }
+ });
+}
+
+module.exports = { agentWebsocket };
diff --git a/server/endpoints/api/admin/index.js b/server/endpoints/api/admin/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..93fbff7669e8b15c00b9a30e58ced556455b80da
--- /dev/null
+++ b/server/endpoints/api/admin/index.js
@@ -0,0 +1,775 @@
+const { EventLogs } = require("../../../models/eventLogs");
+const { Invite } = require("../../../models/invite");
+const { SystemSettings } = require("../../../models/systemSettings");
+const { User } = require("../../../models/user");
+const { Workspace } = require("../../../models/workspace");
+const { WorkspaceChats } = require("../../../models/workspaceChats");
+const { WorkspaceUser } = require("../../../models/workspaceUsers");
+const { canModifyAdmin } = require("../../../utils/helpers/admin");
+const { multiUserMode, reqBody } = require("../../../utils/http");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+
+function apiAdminEndpoints(app) {
+ if (!app) return;
+
+ app.get("/v1/admin/is-multi-user-mode", [validApiKey], (_, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "isMultiUser": true
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ const isMultiUser = multiUserMode(response);
+ response.status(200).json({ isMultiUser });
+ });
+
+ app.get("/v1/admin/users", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "users": [
+ {
+ username: "sample-sam",
+ role: 'default',
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const users = await User.where();
+ response.status(200).json({ users });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post("/v1/admin/users/new", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'Create a new user with username and password. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.requestBody = {
+ description: 'Key pair object that will define the new user to add to the system.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ username: "sample-sam",
+ password: 'hunter2',
+ role: 'default | admin'
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ user: {
+ id: 1,
+ username: 'sample-sam',
+ role: 'default',
+ },
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const newUserParams = reqBody(request);
+ const { user: newUser, error } = await User.create(newUserParams);
+ response.status(newUser ? 200 : 400).json({ user: newUser, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post("/v1/admin/users/:id", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.parameters['id'] = {
+ in: 'path',
+ description: 'id of the user in the database.',
+ required: true,
+ type: 'string'
+ }
+ #swagger.description = 'Update existing user settings. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.requestBody = {
+ description: 'Key pair object that will update the found user. All fields are optional and will not update unless specified.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ username: "sample-sam",
+ password: 'hunter2',
+ role: 'default | admin',
+ suspended: 0,
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { id } = request.params;
+ const updates = reqBody(request);
+ const user = await User.get({ id: Number(id) });
+ const validAdminRoleModification = await canModifyAdmin(user, updates);
+
+ if (!validAdminRoleModification.valid) {
+ response
+ .status(200)
+ .json({ success: false, error: validAdminRoleModification.error });
+ return;
+ }
+
+ const { success, error } = await User.update(id, updates);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.delete(
+ "/v1/admin/users/:id",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'Delete existing user by id. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.parameters['id'] = {
+ in: 'path',
+ description: 'id of the user in the database.',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { id } = request.params;
+ const user = await User.get({ id: Number(id) });
+ await User.delete({ id: user.id });
+ await EventLogs.logEvent("api_user_deleted", {
+ userName: user.username,
+ });
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get("/v1/admin/invites", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'List all existing invitations to instance regardless of status. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "invites": [
+ {
+ id: 1,
+ status: "pending",
+ code: 'abc-123',
+ claimedBy: null
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const invites = await Invite.whereWithUsers();
+ response.status(200).json({ invites });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post("/v1/admin/invite/new", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'Create a new invite code for someone to use to register with instance. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.requestBody = {
+ description: 'Request body for creation parameters of the invitation',
+ required: false,
+ content: {
+ "application/json": {
+ example: {
+ workspaceIds: [1,2,45],
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ invite: {
+ id: 1,
+ status: "pending",
+ code: 'abc-123',
+ },
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const body = reqBody(request);
+ const { invite, error } = await Invite.create({
+ workspaceIds: body?.workspaceIds ?? [],
+ });
+ response.status(200).json({ invite, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.delete(
+ "/v1/admin/invite/:id",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'Deactivates (soft-delete) invite by id. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.parameters['id'] = {
+ in: 'path',
+ description: 'id of the invite in the database.',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { id } = request.params;
+ const { success, error } = await Invite.deactivate(id);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/admin/workspaces/:workspaceId/users",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.parameters['workspaceId'] = {
+ in: 'path',
+ description: 'id of the workspace.',
+ required: true,
+ type: 'string'
+ }
+ #swagger.description = 'Retrieve a list of users with permissions to access the specified workspace.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ users: [
+ {"userId": 1, "role": "admin"},
+ {"userId": 2, "role": "member"}
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const workspaceId = request.params.workspaceId;
+ const users = await Workspace.workspaceUsers(workspaceId);
+
+ response.status(200).json({ users });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/admin/workspaces/:workspaceId/update-users",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.deprecated = true
+ #swagger.parameters['workspaceId'] = {
+ in: 'path',
+ description: 'id of the workspace in the database.',
+ required: true,
+ type: 'string'
+ }
+ #swagger.description = 'Overwrite workspace permissions to only be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.requestBody = {
+ description: 'Entire array of user ids who can access the workspace. All fields are optional and will not update unless specified.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ userIds: [1,2,4,12],
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { workspaceId } = request.params;
+ const { userIds } = reqBody(request);
+ const { success, error } = await Workspace.updateUsers(
+ workspaceId,
+ userIds
+ );
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/admin/workspaces/:workspaceSlug/manage-users",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.parameters['workspaceSlug'] = {
+ in: 'path',
+ description: 'slug of the workspace in the database',
+ required: true,
+ type: 'string'
+ }
+ #swagger.description = 'Set workspace permissions to be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.requestBody = {
+ description: 'Array of user ids who will be given access to the target workspace. reset will remove all existing users from the workspace and only add the new users - default false.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ userIds: [1,2,4,12],
+ reset: false
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ users: [
+ {"userId": 1, "username": "main-admin", "role": "admin"},
+ {"userId": 2, "username": "sample-sam", "role": "default"}
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const { workspaceSlug } = request.params;
+ const { userIds: _uids, reset = false } = reqBody(request);
+ const userIds = (
+ await User.where({ id: { in: _uids.map(Number) } })
+ ).map((user) => user.id);
+ const workspace = await Workspace.get({ slug: String(workspaceSlug) });
+ const workspaceUsers = await Workspace.workspaceUsers(workspace.id);
+
+ if (!workspace) {
+ response.status(404).json({
+ success: false,
+ error: `Workspace ${workspaceSlug} not found`,
+ users: workspaceUsers,
+ });
+ return;
+ }
+
+ if (userIds.length === 0) {
+ response.status(404).json({
+ success: false,
+ error: `No valid user IDs provided.`,
+ users: workspaceUsers,
+ });
+ return;
+ }
+
+ // Reset all users in the workspace and add the new users as the only users in the workspace
+ if (reset) {
+ const { success, error } = await Workspace.updateUsers(
+ workspace.id,
+ userIds
+ );
+ return response.status(200).json({
+ success,
+ error,
+ users: await Workspace.workspaceUsers(workspace.id),
+ });
+ }
+
+ // Add new users to the workspace if they are not already in the workspace
+ const existingUserIds = workspaceUsers.map((user) => user.userId);
+ const usersToAdd = userIds.filter(
+ (userId) => !existingUserIds.includes(userId)
+ );
+ if (usersToAdd.length > 0)
+ await WorkspaceUser.createManyUsers(usersToAdd, workspace.id);
+ response.status(200).json({
+ success: true,
+ error: null,
+ users: await Workspace.workspaceUsers(workspace.id),
+ });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/admin/workspace-chats",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'All chats in the system ordered by most recent. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.requestBody = {
+ description: 'Page offset to show of workspace chats. All fields are optional and will not update unless specified.',
+ required: false,
+ content: {
+ "application/json": {
+ example: {
+ offset: 2,
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const pgSize = 20;
+ const { offset = 0 } = reqBody(request);
+ const chats = await WorkspaceChats.whereWithData(
+ {},
+ pgSize,
+ offset * pgSize,
+ { id: "desc" }
+ );
+
+ const hasPages = (await WorkspaceChats.count()) > (offset + 1) * pgSize;
+ response.status(200).json({ chats: chats, hasPages });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/admin/preferences",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Admin']
+ #swagger.description = 'Update multi-user preferences for instance. Methods are disabled until multi user mode is enabled via the UI.'
+ #swagger.requestBody = {
+ description: 'Object with setting key and new value to set. All keys are optional and will not update unless specified.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ support_email: "support@example.com",
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Method denied",
+ }
+ */
+ try {
+ if (!multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ const updates = reqBody(request);
+ await SystemSettings.updateSettings(updates);
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { apiAdminEndpoints };
diff --git a/server/endpoints/api/auth/index.js b/server/endpoints/api/auth/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e58420b00417f04ec75e946800631fc2aa94983a
--- /dev/null
+++ b/server/endpoints/api/auth/index.js
@@ -0,0 +1,33 @@
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+
+function apiAuthEndpoints(app) {
+ if (!app) return;
+
+ app.get("/v1/auth", [validApiKey], (_, response) => {
+ /*
+ #swagger.tags = ['Authentication']
+ #swagger.description = 'Verify the attached Authentication header contains a valid API token.'
+ #swagger.responses[200] = {
+ description: 'Valid auth token was found.',
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ authenticated: true,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ response.status(200).json({ authenticated: true });
+ });
+}
+
+module.exports = { apiAuthEndpoints };
diff --git a/server/endpoints/api/document/index.js b/server/endpoints/api/document/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..ae8093b4156bf3311177ea218026306d066181d5
--- /dev/null
+++ b/server/endpoints/api/document/index.js
@@ -0,0 +1,1101 @@
+const { Telemetry } = require("../../../models/telemetry");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+const { handleAPIFileUpload } = require("../../../utils/files/multer");
+const {
+ viewLocalFiles,
+ findDocumentInDocuments,
+ getDocumentsByFolder,
+ normalizePath,
+ isWithin,
+} = require("../../../utils/files");
+const { reqBody, safeJsonParse } = require("../../../utils/http");
+const { EventLogs } = require("../../../models/eventLogs");
+const { CollectorApi } = require("../../../utils/collectorApi");
+const fs = require("fs");
+const path = require("path");
+const { Document } = require("../../../models/documents");
+const { purgeFolder } = require("../../../utils/files/purgeDocument");
+const documentsPath =
+ process.env.NODE_ENV === "development"
+ ? path.resolve(__dirname, "../../../storage/documents")
+ : path.resolve(process.env.STORAGE_DIR, `documents`);
+
+function apiDocumentEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/v1/document/upload",
+ [validApiKey, handleAPIFileUpload],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Upload a new file to AnythingLLM to be parsed and prepared for embedding, with optional metadata.'
+ #swagger.requestBody = {
+ description: 'File to be uploaded.',
+ required: true,
+ content: {
+ "multipart/form-data": {
+ schema: {
+ type: 'object',
+ required: ['file'],
+ properties: {
+ file: {
+ type: 'string',
+ format: 'binary',
+ description: 'The file to upload'
+ },
+ addToWorkspaces: {
+ type: 'string',
+ description: 'comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2',
+ },
+ metadata: {
+ type: 'object',
+ description: 'Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.',
+ example: { 'title': 'Custom Title', 'docAuthor': 'Author Name', 'description': 'A brief description', 'docSource': 'Source of the document' }
+ }
+ },
+ required: ['file']
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ documents: [
+ {
+ "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "url": "file:///Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt",
+ "title": "anythingllm.txt",
+ "docAuthor": "Unknown",
+ "description": "Unknown",
+ "docSource": "a text file uploaded by the user.",
+ "chunkSource": "anythingllm.txt",
+ "published": "1/16/2024, 3:07:00 PM",
+ "wordCount": 93,
+ "token_count_estimate": 115,
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const Collector = new CollectorApi();
+ const { originalname } = request.file;
+ const { addToWorkspaces = "", metadata: _metadata = {} } =
+ reqBody(request);
+ const metadata =
+ typeof _metadata === "string"
+ ? safeJsonParse(_metadata, {})
+ : _metadata;
+ const processingOnline = await Collector.online();
+
+ if (!processingOnline) {
+ response
+ .status(500)
+ .json({
+ success: false,
+ error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`,
+ })
+ .end();
+ return;
+ }
+
+ const { success, reason, documents } = await Collector.processDocument(
+ originalname,
+ metadata
+ );
+
+ if (!success) {
+ return response
+ .status(500)
+ .json({ success: false, error: reason, documents })
+ .end();
+ }
+
+ Collector.log(
+ `Document ${originalname} uploaded processed and successfully. It is now available in documents.`
+ );
+ await Telemetry.sendTelemetry("document_uploaded");
+ await EventLogs.logEvent("api_document_uploaded", {
+ documentName: originalname,
+ });
+
+ if (!!addToWorkspaces)
+ await Document.api.uploadToWorkspace(
+ addToWorkspaces,
+ documents?.[0].location
+ );
+ response.status(200).json({ success: true, error: null, documents });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/document/upload/:folderName",
+ [validApiKey, handleAPIFileUpload],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Upload a new file to a specific folder in AnythingLLM to be parsed and prepared for embedding. If the folder does not exist, it will be created.'
+ #swagger.parameters['folderName'] = {
+ in: 'path',
+ description: 'Target folder path (defaults to \"custom-documents\" if not provided)',
+ required: true,
+ type: 'string',
+ example: 'my-folder'
+ }
+ #swagger.requestBody = {
+ description: 'File to be uploaded, with optional metadata.',
+ required: true,
+ content: {
+ "multipart/form-data": {
+ schema: {
+ type: 'object',
+ required: ['file'],
+ properties: {
+ file: {
+ type: 'string',
+ format: 'binary',
+ description: 'The file to upload'
+ },
+ addToWorkspaces: {
+ type: 'string',
+ description: 'comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2',
+ },
+ metadata: {
+ type: 'object',
+ description: 'Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.',
+ example: { 'title': 'Custom Title', 'docAuthor': 'Author Name', 'description': 'A brief description', 'docSource': 'Source of the document' }
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ documents: [{
+ "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "url": "file:///Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt",
+ "title": "anythingllm.txt",
+ "docAuthor": "Unknown",
+ "description": "Unknown",
+ "docSource": "a text file uploaded by the user.",
+ "chunkSource": "anythingllm.txt",
+ "published": "1/16/2024, 3:07:00 PM",
+ "wordCount": 93,
+ "token_count_estimate": 115
+ }]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[500] = {
+ description: "Internal Server Error",
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: false,
+ error: "Document processing API is not online. Document will not be processed automatically."
+ }
+ }
+ }
+ }
+ }
+ */
+ try {
+ const { originalname } = request.file;
+ const { addToWorkspaces = "", metadata: _metadata = {} } =
+ reqBody(request);
+ const metadata =
+ typeof _metadata === "string"
+ ? safeJsonParse(_metadata, {})
+ : _metadata;
+
+ let folder = request.params?.folderName || "custom-documents";
+ folder = normalizePath(folder);
+ const targetFolderPath = path.join(documentsPath, folder);
+
+ if (
+ !isWithin(path.resolve(documentsPath), path.resolve(targetFolderPath))
+ )
+ throw new Error("Invalid folder name");
+ if (!fs.existsSync(targetFolderPath))
+ fs.mkdirSync(targetFolderPath, { recursive: true });
+
+ const Collector = new CollectorApi();
+ const processingOnline = await Collector.online();
+ if (!processingOnline) {
+ return response
+ .status(500)
+ .json({
+ success: false,
+ error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`,
+ })
+ .end();
+ }
+
+ // Process the uploaded document with metadata
+ const { success, reason, documents } = await Collector.processDocument(
+ originalname,
+ metadata
+ );
+ if (!success) {
+ return response
+ .status(500)
+ .json({ success: false, error: reason, documents })
+ .end();
+ }
+
+ // For each processed document, check if it is already in the desired folder.
+ // If not, move it using similar logic as in the move-files endpoint.
+ for (const doc of documents) {
+ const currentFolder = path.dirname(doc.location);
+ if (currentFolder !== folder) {
+ const sourcePath = path.join(
+ documentsPath,
+ normalizePath(doc.location)
+ );
+ const destinationPath = path.join(
+ targetFolderPath,
+ path.basename(doc.location)
+ );
+
+ if (
+ !isWithin(documentsPath, sourcePath) ||
+ !isWithin(documentsPath, destinationPath)
+ )
+ throw new Error("Invalid file location");
+
+ fs.renameSync(sourcePath, destinationPath);
+ doc.location = path.join(folder, path.basename(doc.location));
+ doc.name = path.basename(doc.location);
+ }
+ }
+
+ Collector.log(
+ `Document ${originalname} uploaded, processed, and moved to folder ${folder} successfully.`
+ );
+
+ await Telemetry.sendTelemetry("document_uploaded");
+ await EventLogs.logEvent("api_document_uploaded", {
+ documentName: originalname,
+ folder,
+ });
+
+ if (!!addToWorkspaces)
+ await Document.api.uploadToWorkspace(
+ addToWorkspaces,
+ documents?.[0].location
+ );
+ response.status(200).json({ success: true, error: null, documents });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/document/upload-link",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Upload a valid URL for AnythingLLM to scrape and prepare for embedding. Optionally, specify a comma-separated list of workspace slugs to embed the document into post-upload.'
+ #swagger.requestBody = {
+ description: 'Link of web address to be scraped and optionally a comma-separated list of workspace slugs to embed the document into post-upload, and optional metadata.',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "link": "https://anythingllm.com",
+ "addToWorkspaces": "workspace1,workspace2",
+ "scraperHeaders": {
+ "Authorization": "Bearer token123",
+ "My-Custom-Header": "value"
+ },
+ "metadata": {
+ "title": "Custom Title",
+ "docAuthor": "Author Name",
+ "description": "A brief description",
+ "docSource": "Source of the document"
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ documents: [
+ {
+ "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc",
+ "url": "file://useanything_com.html",
+ "title": "useanything_com.html",
+ "docAuthor": "no author found",
+ "description": "No description found.",
+ "docSource": "URL link uploaded by the user.",
+ "chunkSource": "https:anythingllm.com.html",
+ "published": "1/16/2024, 3:46:33 PM",
+ "wordCount": 252,
+ "pageContent": "AnythingLLM is the best....",
+ "token_count_estimate": 447,
+ "location": "custom-documents/url-useanything_com-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const Collector = new CollectorApi();
+ const {
+ link,
+ addToWorkspaces = "",
+ scraperHeaders = {},
+ metadata: _metadata = {},
+ } = reqBody(request);
+ const metadata =
+ typeof _metadata === "string"
+ ? safeJsonParse(_metadata, {})
+ : _metadata;
+ const processingOnline = await Collector.online();
+
+ if (!processingOnline) {
+ return response
+ .status(500)
+ .json({
+ success: false,
+ error: `Document processing API is not online. Link ${link} will not be processed automatically.`,
+ })
+ .end();
+ }
+
+ const { success, reason, documents } = await Collector.processLink(
+ link,
+ scraperHeaders,
+ metadata
+ );
+ if (!success) {
+ return response
+ .status(500)
+ .json({ success: false, error: reason, documents })
+ .end();
+ }
+
+ Collector.log(
+ `Link ${link} uploaded processed and successfully. It is now available in documents.`
+ );
+ await Telemetry.sendTelemetry("link_uploaded");
+ await EventLogs.logEvent("api_link_uploaded", {
+ link,
+ });
+
+ if (!!addToWorkspaces)
+ await Document.api.uploadToWorkspace(
+ addToWorkspaces,
+ documents?.[0].location
+ );
+ response.status(200).json({ success: true, error: null, documents });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/document/raw-text",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Upload a file by specifying its raw text content and metadata values without having to upload a file.'
+ #swagger.requestBody = {
+ description: 'Text content and metadata of the file to be saved to the system. Use metadata-schema endpoint to get the possible metadata keys',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "textContent": "This is the raw text that will be saved as a document in AnythingLLM.",
+ "addToWorkspaces": "workspace1,workspace2",
+ "metadata": {
+ "title": "This key is required. See in /server/endpoints/api/document/index.js:287",
+ "keyOne": "valueOne",
+ "keyTwo": "valueTwo",
+ "etc": "etc"
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ error: null,
+ documents: [
+ {
+ "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc",
+ "url": "file://my-document.txt",
+ "title": "hello-world.txt",
+ "docAuthor": "no author found",
+ "description": "No description found.",
+ "docSource": "My custom description set during upload",
+ "chunkSource": "no chunk source specified",
+ "published": "1/16/2024, 3:46:33 PM",
+ "wordCount": 252,
+ "pageContent": "AnythingLLM is the best....",
+ "token_count_estimate": 447,
+ "location": "custom-documents/raw-my-doc-text-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const Collector = new CollectorApi();
+ const requiredMetadata = ["title"];
+ const {
+ textContent,
+ metadata: _metadata = {},
+ addToWorkspaces = "",
+ } = reqBody(request);
+ const metadata =
+ typeof _metadata === "string"
+ ? safeJsonParse(_metadata, {})
+ : _metadata;
+ const processingOnline = await Collector.online();
+
+ if (!processingOnline) {
+ return response
+ .status(500)
+ .json({
+ success: false,
+ error: `Document processing API is not online. Request will not be processed.`,
+ })
+ .end();
+ }
+
+ if (
+ !requiredMetadata.every(
+ (reqKey) =>
+ Object.keys(metadata).includes(reqKey) && !!metadata[reqKey]
+ )
+ ) {
+ return response
+ .status(422)
+ .json({
+ success: false,
+ error: `You are missing required metadata key:value pairs in your request. Required metadata key:values are ${requiredMetadata
+ .map((v) => `'${v}'`)
+ .join(", ")}`,
+ })
+ .end();
+ }
+
+ if (!textContent || textContent?.length === 0) {
+ return response
+ .status(422)
+ .json({
+ success: false,
+ error: `The 'textContent' key cannot have an empty value.`,
+ })
+ .end();
+ }
+
+ const { success, reason, documents } = await Collector.processRawText(
+ textContent,
+ metadata
+ );
+ if (!success) {
+ return response
+ .status(500)
+ .json({ success: false, error: reason, documents })
+ .end();
+ }
+
+ Collector.log(
+ `Document created successfully. It is now available in documents.`
+ );
+ await Telemetry.sendTelemetry("raw_document_uploaded");
+ await EventLogs.logEvent("api_raw_document_uploaded");
+
+ if (!!addToWorkspaces)
+ await Document.api.uploadToWorkspace(
+ addToWorkspaces,
+ documents?.[0].location
+ );
+ response.status(200).json({ success: true, error: null, documents });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get("/v1/documents", [validApiKey], async (_, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'List of all locally-stored documents in instance'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "localFiles": {
+ "name": "documents",
+ "type": "folder",
+ items: [
+ {
+ "name": "my-stored-document.json",
+ "type": "file",
+ "id": "bb07c334-4dab-4419-9462-9d00065a49a1",
+ "url": "file://my-stored-document.txt",
+ "title": "my-stored-document.txt",
+ "cached": false
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const localFiles = await viewLocalFiles();
+ response.status(200).json({ localFiles });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get(
+ "/v1/documents/folder/:folderName",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Get all documents stored in a specific folder.'
+ #swagger.parameters['folderName'] = {
+ in: 'path',
+ description: 'Name of the folder to retrieve documents from',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ folder: "custom-documents",
+ documents: [
+ {
+ name: "document1.json",
+ type: "file",
+ cached: false,
+ pinnedWorkspaces: [],
+ watched: false,
+ more: "data",
+ },
+ {
+ name: "document2.json",
+ type: "file",
+ cached: false,
+ pinnedWorkspaces: [],
+ watched: false,
+ more: "data",
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { folderName } = request.params;
+ const result = await getDocumentsByFolder(folderName);
+ response.status(result.code).json({
+ folder: result.folder,
+ documents: result.documents,
+ error: result.error,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/document/accepted-file-types",
+ [validApiKey],
+ async (_, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Check available filetypes and MIMEs that can be uploaded.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "types": {
+ "application/mbox": [
+ ".mbox"
+ ],
+ "application/pdf": [
+ ".pdf"
+ ],
+ "application/vnd.oasis.opendocument.text": [
+ ".odt"
+ ],
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
+ ".docx"
+ ],
+ "text/plain": [
+ ".txt",
+ ".md"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const types = await new CollectorApi().acceptedFileTypes();
+ if (!types) {
+ response.sendStatus(404).end();
+ return;
+ }
+
+ response.status(200).json({ types });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/document/metadata-schema",
+ [validApiKey],
+ async (_, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Get the known available metadata schema for when doing a raw-text upload and the acceptable type of value for each key.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "schema": {
+ "keyOne": "string | number | nullable",
+ "keyTwo": "string | number | nullable",
+ "specialKey": "number",
+ "title": "string",
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ response.status(200).json({
+ schema: {
+ // If you are updating this be sure to update the collector METADATA_KEYS constant in /processRawText.
+ url: "string | nullable",
+ title: "string",
+ docAuthor: "string | nullable",
+ description: "string | nullable",
+ docSource: "string | nullable",
+ chunkSource: "string | nullable",
+ published: "epoch timestamp in ms | nullable",
+ },
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ // Be careful and place as last route to prevent override of the other /document/ GET
+ // endpoints!
+ app.get("/v1/document/:docName", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Get a single document by its unique AnythingLLM document name'
+ #swagger.parameters['docName'] = {
+ in: 'path',
+ description: 'Unique document name to find (name in /documents)',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "localFiles": {
+ "name": "documents",
+ "type": "folder",
+ items: [
+ {
+ "name": "my-stored-document.txt-uuid1234.json",
+ "type": "file",
+ "id": "bb07c334-4dab-4419-9462-9d00065a49a1",
+ "url": "file://my-stored-document.txt",
+ "title": "my-stored-document.txt",
+ "cached": false
+ },
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { docName } = request.params;
+ const document = await findDocumentInDocuments(docName);
+ if (!document) {
+ response.sendStatus(404).end();
+ return;
+ }
+ response.status(200).json({ document });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post(
+ "/v1/document/create-folder",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Create a new folder inside the documents storage directory.'
+ #swagger.requestBody = {
+ description: 'Name of the folder to create.',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'string',
+ example: {
+ "name": "new-folder"
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ message: null
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { name } = reqBody(request);
+ const storagePath = path.join(documentsPath, normalizePath(name));
+ if (!isWithin(path.resolve(documentsPath), path.resolve(storagePath)))
+ throw new Error("Invalid path name");
+
+ if (fs.existsSync(storagePath)) {
+ response.status(500).json({
+ success: false,
+ message: "Folder by that name already exists",
+ });
+ return;
+ }
+
+ fs.mkdirSync(storagePath, { recursive: true });
+ response.status(200).json({ success: true, message: null });
+ } catch (e) {
+ console.error(e);
+ response.status(500).json({
+ success: false,
+ message: `Failed to create folder: ${e.message}`,
+ });
+ }
+ }
+ );
+
+ app.delete(
+ "/v1/document/remove-folder",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Remove a folder and all its contents from the documents storage directory.'
+ #swagger.requestBody = {
+ description: 'Name of the folder to remove.',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ example: "my-folder"
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ message: "Folder removed successfully"
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { name } = reqBody(request);
+ await purgeFolder(name);
+ response
+ .status(200)
+ .json({ success: true, message: "Folder removed successfully" });
+ } catch (e) {
+ console.error(e);
+ response.status(500).json({
+ success: false,
+ message: `Failed to remove folder: ${e.message}`,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/v1/document/move-files",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Documents']
+ #swagger.description = 'Move files within the documents storage directory.'
+ #swagger.requestBody = {
+ description: 'Array of objects containing source and destination paths of files to move.',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "files": [
+ {
+ "from": "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json",
+ "to": "folder/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ message: null
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { files } = reqBody(request);
+ const docpaths = files.map(({ from }) => from);
+ const documents = await Document.where({ docpath: { in: docpaths } });
+ const embeddedFiles = documents.map((doc) => doc.docpath);
+ const moveableFiles = files.filter(
+ ({ from }) => !embeddedFiles.includes(from)
+ );
+ const movePromises = moveableFiles.map(({ from, to }) => {
+ const sourcePath = path.join(documentsPath, normalizePath(from));
+ const destinationPath = path.join(documentsPath, normalizePath(to));
+ return new Promise((resolve, reject) => {
+ if (
+ !isWithin(documentsPath, sourcePath) ||
+ !isWithin(documentsPath, destinationPath)
+ )
+ return reject("Invalid file location");
+
+ fs.rename(sourcePath, destinationPath, (err) => {
+ if (err) {
+ console.error(`Error moving file ${from} to ${to}:`, err);
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+ });
+ Promise.all(movePromises)
+ .then(() => {
+ const unmovableCount = files.length - moveableFiles.length;
+ if (unmovableCount > 0) {
+ response.status(200).json({
+ success: true,
+ message: `${unmovableCount}/${files.length} files not moved. Unembed them from all workspaces.`,
+ });
+ } else {
+ response.status(200).json({
+ success: true,
+ message: null,
+ });
+ }
+ })
+ .catch((err) => {
+ console.error("Error moving files:", err);
+ response
+ .status(500)
+ .json({ success: false, message: "Failed to move some files." });
+ });
+ } catch (e) {
+ console.error(e);
+ response
+ .status(500)
+ .json({ success: false, message: "Failed to move files." });
+ }
+ }
+ );
+}
+
+module.exports = { apiDocumentEndpoints };
diff --git a/server/endpoints/api/embed/index.js b/server/endpoints/api/embed/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..dba6020f4baa96442c59433508def401c0ff7a72
--- /dev/null
+++ b/server/endpoints/api/embed/index.js
@@ -0,0 +1,409 @@
+const { EmbedConfig } = require("../../../models/embedConfig");
+const { EmbedChats } = require("../../../models/embedChats");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+const { reqBody } = require("../../../utils/http");
+const { Workspace } = require("../../../models/workspace");
+
+function apiEmbedEndpoints(app) {
+ if (!app) return;
+
+ app.get("/v1/embed", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Embed']
+ #swagger.description = 'List all active embeds'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ embeds: [
+ {
+ "id": 1,
+ "uuid": "embed-uuid-1",
+ "enabled": true,
+ "chat_mode": "query",
+ "createdAt": "2023-04-01T12:00:00Z",
+ "workspace": {
+ "id": 1,
+ "name": "Workspace 1"
+ },
+ "chat_count": 10
+ },
+ {
+ "id": 2,
+ "uuid": "embed-uuid-2",
+ "enabled": false,
+ "chat_mode": "chat",
+ "createdAt": "2023-04-02T14:30:00Z",
+ "workspace": {
+ "id": 1,
+ "name": "Workspace 1"
+ },
+ "chat_count": 10
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const embeds = await EmbedConfig.whereWithWorkspace();
+ const filteredEmbeds = embeds.map((embed) => ({
+ id: embed.id,
+ uuid: embed.uuid,
+ enabled: embed.enabled,
+ chat_mode: embed.chat_mode,
+ createdAt: embed.createdAt,
+ workspace: {
+ id: embed.workspace.id,
+ name: embed.workspace.name,
+ },
+ chat_count: embed._count.embed_chats,
+ }));
+ response.status(200).json({ embeds: filteredEmbeds });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get(
+ "/v1/embed/:embedUuid/chats",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Embed']
+ #swagger.description = 'Get all chats for a specific embed'
+ #swagger.parameters['embedUuid'] = {
+ in: 'path',
+ description: 'UUID of the embed',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ chats: [
+ {
+ "id": 1,
+ "session_id": "session-uuid-1",
+ "prompt": "Hello",
+ "response": "Hi there!",
+ "createdAt": "2023-04-01T12:00:00Z"
+ },
+ {
+ "id": 2,
+ "session_id": "session-uuid-2",
+ "prompt": "How are you?",
+ "response": "I'm doing well, thank you!",
+ "createdAt": "2023-04-02T14:30:00Z"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[404] = {
+ description: "Embed not found",
+ }
+ */
+ try {
+ const { embedUuid } = request.params;
+ const chats = await EmbedChats.where({
+ embed_config: { uuid: String(embedUuid) },
+ });
+ response.status(200).json({ chats });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/embed/:embedUuid/chats/:sessionUuid",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Embed']
+ #swagger.description = 'Get chats for a specific embed and session'
+ #swagger.parameters['embedUuid'] = {
+ in: 'path',
+ description: 'UUID of the embed',
+ required: true,
+ type: 'string'
+ }
+ #swagger.parameters['sessionUuid'] = {
+ in: 'path',
+ description: 'UUID of the session',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ chats: [
+ {
+ "id": 1,
+ "prompt": "Hello",
+ "response": "Hi there!",
+ "createdAt": "2023-04-01T12:00:00Z"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[404] = {
+ description: "Embed or session not found",
+ }
+ */
+ try {
+ const { embedUuid, sessionUuid } = request.params;
+ const chats = await EmbedChats.where({
+ embed_config: { uuid: String(embedUuid) },
+ session_id: String(sessionUuid),
+ });
+ response.status(200).json({ chats });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post("/v1/embed/new", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Embed']
+ #swagger.description = 'Create a new embed configuration'
+ #swagger.requestBody = {
+ description: 'JSON object containing embed configuration details',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "workspace_slug": "workspace-slug-1",
+ "chat_mode": "chat",
+ "allowlist_domains": ["example.com"],
+ "allow_model_override": false,
+ "allow_temperature_override": false,
+ "allow_prompt_override": false,
+ "max_chats_per_day": 100,
+ "max_chats_per_session": 10
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "embed": {
+ "id": 1,
+ "uuid": "embed-uuid-1",
+ "enabled": true,
+ "chat_mode": "chat",
+ "allowlist_domains": ["example.com"],
+ "allow_model_override": false,
+ "allow_temperature_override": false,
+ "allow_prompt_override": false,
+ "max_chats_per_day": 100,
+ "max_chats_per_session": 10,
+ "createdAt": "2023-04-01T12:00:00Z",
+ "workspace_slug": "workspace-slug-1"
+ },
+ "error": null
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[404] = {
+ description: "Workspace not found"
+ }
+ */
+ try {
+ const data = reqBody(request);
+
+ if (!data.workspace_slug)
+ return response
+ .status(400)
+ .json({ error: "Workspace slug is required" });
+ const workspace = await Workspace.get({
+ slug: String(data.workspace_slug),
+ });
+
+ if (!workspace)
+ return response.status(404).json({ error: "Workspace not found" });
+
+ const { embed, message: error } = await EmbedConfig.new({
+ ...data,
+ workspace_id: workspace.id,
+ });
+
+ response.status(200).json({ embed, error });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post("/v1/embed/:embedUuid", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Embed']
+ #swagger.description = 'Update an existing embed configuration'
+ #swagger.parameters['embedUuid'] = {
+ in: 'path',
+ description: 'UUID of the embed to update',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'JSON object containing embed configuration updates',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "enabled": true,
+ "chat_mode": "chat",
+ "allowlist_domains": ["example.com"],
+ "allow_model_override": false,
+ "allow_temperature_override": false,
+ "allow_prompt_override": false,
+ "max_chats_per_day": 100,
+ "max_chats_per_session": 10
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[404] = {
+ description: "Embed not found"
+ }
+ */
+ try {
+ const { embedUuid } = request.params;
+ const data = reqBody(request);
+
+ const embed = await EmbedConfig.get({ uuid: String(embedUuid) });
+ if (!embed) {
+ return response.status(404).json({ error: "Embed not found" });
+ }
+
+ const { success, error } = await EmbedConfig.update(embed.id, data);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.delete(
+ "/v1/embed/:embedUuid",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Embed']
+ #swagger.description = 'Delete an existing embed configuration'
+ #swagger.parameters['embedUuid'] = {
+ in: 'path',
+ description: 'UUID of the embed to delete',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[404] = {
+ description: "Embed not found"
+ }
+ */
+ try {
+ const { embedUuid } = request.params;
+ const embed = await EmbedConfig.get({ uuid: String(embedUuid) });
+ if (!embed)
+ return response.status(404).json({ error: "Embed not found" });
+ const success = await EmbedConfig.delete({ id: embed.id });
+ response
+ .status(200)
+ .json({ success, error: success ? null : "Failed to delete embed" });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { apiEmbedEndpoints };
diff --git a/server/endpoints/api/index.js b/server/endpoints/api/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..6879c0150aee4e8ae9a7e9cbc2b3a814b86dc7e4
--- /dev/null
+++ b/server/endpoints/api/index.js
@@ -0,0 +1,29 @@
+const { useSwagger } = require("../../swagger/utils");
+const { apiAdminEndpoints } = require("./admin");
+const { apiAuthEndpoints } = require("./auth");
+const { apiDocumentEndpoints } = require("./document");
+const { apiSystemEndpoints } = require("./system");
+const { apiWorkspaceEndpoints } = require("./workspace");
+const { apiWorkspaceThreadEndpoints } = require("./workspaceThread");
+const { apiUserManagementEndpoints } = require("./userManagement");
+const { apiOpenAICompatibleEndpoints } = require("./openai");
+const { apiEmbedEndpoints } = require("./embed");
+
+// All endpoints must be documented and pass through the validApiKey Middleware.
+// How to JSDoc an endpoint
+// https://www.npmjs.com/package/swagger-autogen#openapi-3x
+function developerEndpoints(app, router) {
+ if (!router) return;
+ useSwagger(app);
+ apiAuthEndpoints(router);
+ apiAdminEndpoints(router);
+ apiSystemEndpoints(router);
+ apiWorkspaceEndpoints(router);
+ apiDocumentEndpoints(router);
+ apiWorkspaceThreadEndpoints(router);
+ apiUserManagementEndpoints(router);
+ apiOpenAICompatibleEndpoints(router);
+ apiEmbedEndpoints(router);
+}
+
+module.exports = { developerEndpoints };
diff --git a/server/endpoints/api/openai/compatibility-test-script.cjs b/server/endpoints/api/openai/compatibility-test-script.cjs
new file mode 100644
index 0000000000000000000000000000000000000000..96e56b0734aa9ae795b042e5af1c15b26ccb7525
--- /dev/null
+++ b/server/endpoints/api/openai/compatibility-test-script.cjs
@@ -0,0 +1,79 @@
+const OpenAI = require("openai");
+
+/**
+ * @type {import("openai").OpenAI}
+ */
+const client = new OpenAI({
+ baseURL: "http://localhost:3001/api/v1/openai",
+ apiKey: "ENTER_ANYTHINGLLM_API_KEY_HERE",
+});
+
+(async () => {
+ // Models endpoint testing.
+ console.log("Fetching /models");
+ const modelList = await client.models.list();
+ for await (const model of modelList) {
+ console.log({ model });
+ }
+
+ // Test sync chat completion
+ console.log("Running synchronous chat message");
+ const syncCompletion = await client.chat.completions.create({
+ messages: [
+ {
+ role: "system",
+ content: "You are a helpful assistant who only speaks like a pirate.",
+ },
+ { role: "user", content: "What is AnythingLLM?" },
+ // {
+ // role: 'assistant',
+ // content: "Arrr, matey! AnythingLLM be a fine tool fer sailin' the treacherous sea o' information with a powerful language model at yer helm. It's a potent instrument to handle all manner o' tasks involvin' text, like answerin' questions, generating prose, or even havin' a chat with digital scallywags like meself. Be there any specific treasure ye seek in the realm o' AnythingLLM?"
+ // },
+ // { role: "user", content: "Why are you talking like a pirate?" },
+ ],
+ model: "anythingllm", // must be workspace-slug
+ });
+ console.log(syncCompletion.choices[0]);
+
+ // Test sync chat streaming completion
+ console.log("Running asynchronous chat message");
+ const asyncCompletion = await client.chat.completions.create({
+ messages: [
+ {
+ role: "system",
+ content: "You are a helpful assistant who only speaks like a pirate.",
+ },
+ { role: "user", content: "What is AnythingLLM?" },
+ ],
+ model: "anythingllm", // must be workspace-slug
+ stream: true,
+ });
+
+ let message = "";
+ for await (const chunk of asyncCompletion) {
+ message += chunk.choices[0].delta.content;
+ console.log({ message });
+ }
+
+ // Test embeddings creation
+ console.log("Creating embeddings");
+ const embedding = await client.embeddings.create({
+ model: null, // model is optional for AnythingLLM
+ input: "This is a test string for embedding",
+ encoding_format: "float",
+ });
+ console.log("Embedding created successfully:");
+ console.log(`Dimensions: ${embedding.data[0].embedding.length}`);
+ console.log(
+ `First few values:`,
+ embedding.data[0].embedding.slice(0, 5),
+ `+ ${embedding.data[0].embedding.length - 5} more`
+ );
+
+ // Vector DB functionality
+ console.log("Fetching /vector_stores");
+ const vectorDBList = await client.beta.vectorStores.list();
+ for await (const db of vectorDBList) {
+ console.log(db);
+ }
+})();
diff --git a/server/endpoints/api/openai/helpers.js b/server/endpoints/api/openai/helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..6d54f770494e8d2705c376dfd5d91194603d6f25
--- /dev/null
+++ b/server/endpoints/api/openai/helpers.js
@@ -0,0 +1,50 @@
+/**
+ * Extracts text content from a multimodal message
+ * If the content has multiple text items, it will join them together with a newline.
+ * @param {string|Array} content - Message content that could be string or array of content objects
+ * @returns {string} - The text content
+ */
+function extractTextContent(content) {
+ if (!Array.isArray(content)) return content;
+ return content
+ .filter((item) => item.type === "text")
+ .map((item) => item.text)
+ .join("\n");
+}
+
+/**
+ * Detects mime type from a base64 data URL string, defaults to PNG if not detected
+ * @param {string} dataUrl - The data URL string (e.g. data:image/jpeg;base64,...)
+ * @returns {string} - The mime type or 'image/png' if not detected
+ */
+function getMimeTypeFromDataUrl(dataUrl) {
+ try {
+ const matches = dataUrl.match(/^data:([^;]+);base64,/);
+ return matches ? matches[1].toLowerCase() : "image/png";
+ } catch (e) {
+ return "image/png";
+ }
+}
+
+/**
+ * Extracts attachments from a multimodal message
+ * The attachments provided are in OpenAI format since this util is used in the OpenAI compatible chat.
+ * However, our backend internal chat uses the Attachment type we use elsewhere in the app so we have to convert it.
+ * @param {Array} content - Message content that could be string or array of content objects
+ * @returns {import("../../../utils/helpers").Attachment[]} - The attachments
+ */
+function extractAttachments(content) {
+ if (!Array.isArray(content)) return [];
+ return content
+ .filter((item) => item.type === "image_url")
+ .map((item, index) => ({
+ name: `uploaded_image_${index}`,
+ mime: getMimeTypeFromDataUrl(item.image_url.url),
+ contentString: item.image_url.url,
+ }));
+}
+
+module.exports = {
+ extractTextContent,
+ extractAttachments,
+};
diff --git a/server/endpoints/api/openai/index.js b/server/endpoints/api/openai/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..132f75b6b7cc8781746ab02a52af277e7cc4d746
--- /dev/null
+++ b/server/endpoints/api/openai/index.js
@@ -0,0 +1,344 @@
+const { v4: uuidv4 } = require("uuid");
+const { Document } = require("../../../models/documents");
+const { Telemetry } = require("../../../models/telemetry");
+const { Workspace } = require("../../../models/workspace");
+const {
+ getLLMProvider,
+ getEmbeddingEngineSelection,
+} = require("../../../utils/helpers");
+const { reqBody } = require("../../../utils/http");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+const { EventLogs } = require("../../../models/eventLogs");
+const {
+ OpenAICompatibleChat,
+} = require("../../../utils/chats/openaiCompatible");
+const { getModelTag } = require("../../utils");
+const { extractTextContent, extractAttachments } = require("./helpers");
+
+function apiOpenAICompatibleEndpoints(app) {
+ if (!app) return;
+
+ app.get("/v1/openai/models", [validApiKey], async (_, response) => {
+ /*
+ #swagger.tags = ['OpenAI Compatible Endpoints']
+ #swagger.description = 'Get all available "models" which are workspaces you can use for chatting.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "object": "list",
+ "data": [
+ {
+ "id": "model-id-0",
+ "object": "model",
+ "created": 1686935002,
+ "owned_by": "organization-owner"
+ },
+ {
+ "id": "model-id-1",
+ "object": "model",
+ "created": 1686935002,
+ "owned_by": "organization-owner"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const data = [];
+ const workspaces = await Workspace.where();
+ for (const workspace of workspaces) {
+ const provider = workspace?.chatProvider ?? process.env.LLM_PROVIDER;
+ let LLMProvider = getLLMProvider({
+ provider,
+ model: workspace?.chatModel,
+ });
+ data.push({
+ id: workspace.slug,
+ object: "model",
+ created: Math.floor(Number(new Date(workspace.createdAt)) / 1000),
+ owned_by: `${provider}-${LLMProvider.model}`,
+ });
+ }
+ return response.status(200).json({
+ object: "list",
+ data,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post(
+ "/v1/openai/chat/completions",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['OpenAI Compatible Endpoints']
+ #swagger.description = 'Execute a chat with a workspace with OpenAI compatibility. Supports streaming as well. Model must be a workspace slug from /models.'
+ #swagger.requestBody = {
+ description: 'Send a prompt to the workspace with full use of documents as if sending a chat in AnythingLLM. Only supports some values of OpenAI API. See example below.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ messages: [
+ {"role":"system", content: "You are a helpful assistant"},
+ {"role":"user", content: "What is AnythingLLM?"},
+ {"role":"assistant", content: "AnythingLLM is...."},
+ {"role":"user", content: "Follow up question..."}
+ ],
+ model: "sample-workspace",
+ stream: true,
+ temperature: 0.7
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const {
+ model,
+ messages = [],
+ temperature,
+ stream = false,
+ } = reqBody(request);
+ const workspace = await Workspace.get({ slug: String(model) });
+ if (!workspace) return response.status(401).end();
+
+ const userMessage = messages.pop();
+ if (userMessage.role !== "user") {
+ return response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error:
+ "No user prompt found. Must be last element in message array with 'user' role.",
+ });
+ }
+
+ const systemPrompt =
+ messages.find((chat) => chat.role === "system")?.content ?? null;
+ const history = messages.filter((chat) => chat.role !== "system") ?? [];
+
+ if (!stream) {
+ const chatResult = await OpenAICompatibleChat.chatSync({
+ workspace,
+ systemPrompt,
+ history,
+ prompt: extractTextContent(userMessage.content),
+ attachments: extractAttachments(userMessage.content),
+ temperature: Number(temperature),
+ });
+
+ await Telemetry.sendTelemetry("sent_chat", {
+ LLMSelection:
+ workspace.chatProvider ?? process.env.LLM_PROVIDER ?? "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ });
+ await EventLogs.logEvent("api_sent_chat", {
+ workspaceName: workspace?.name,
+ chatModel: workspace?.chatModel || "System Default",
+ });
+ return response.status(200).json(chatResult);
+ }
+
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Content-Type", "text/event-stream");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Connection", "keep-alive");
+ response.flushHeaders();
+
+ await OpenAICompatibleChat.streamChat({
+ workspace,
+ systemPrompt,
+ history,
+ prompt: extractTextContent(userMessage.content),
+ attachments: extractAttachments(userMessage.content),
+ temperature: Number(temperature),
+ response,
+ });
+ await Telemetry.sendTelemetry("sent_chat", {
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ });
+ await EventLogs.logEvent("api_sent_chat", {
+ workspaceName: workspace?.name,
+ chatModel: workspace?.chatModel || "System Default",
+ });
+ response.end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/openai/embeddings",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['OpenAI Compatible Endpoints']
+ #swagger.description = 'Get the embeddings of any arbitrary text string. This will use the embedder provider set in the system. Please ensure the token length of each string fits within the context of your embedder model.'
+ #swagger.requestBody = {
+ description: 'The input string(s) to be embedded. If the text is too long for the embedder model context, it will fail to embed. The vector and associated chunk metadata will be returned in the array order provided',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ input: [
+ "This is my first string to embed",
+ "This is my second string to embed",
+ ],
+ model: null,
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const body = reqBody(request);
+ // Support input or "inputs" (for backwards compatibility) as an array of strings or a single string
+ // TODO: "inputs" key support will eventually be fully removed.
+ let input = body?.input || body?.inputs || [];
+ // if input is not an array, make it an array and force to string content
+ if (!Array.isArray(input)) input = [String(input)];
+
+ if (Array.isArray(input)) {
+ if (input.length === 0)
+ throw new Error("Input array cannot be empty.");
+ const validArray = input.every((text) => typeof text === "string");
+ if (!validArray)
+ throw new Error("All inputs to be embedded must be strings.");
+ }
+
+ const Embedder = getEmbeddingEngineSelection();
+ const embeddings = await Embedder.embedChunks(input);
+ const data = [];
+ embeddings.forEach((embedding, index) => {
+ data.push({
+ object: "embedding",
+ embedding,
+ index,
+ });
+ });
+
+ return response.status(200).json({
+ object: "list",
+ data,
+ model: Embedder.model,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/openai/vector_stores",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['OpenAI Compatible Endpoints']
+ #swagger.description = 'List all the vector database collections connected to AnythingLLM. These are essentially workspaces but return their unique vector db identifier - this is the same as the workspace slug.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "data": [
+ {
+ "id": "slug-here",
+ "object": "vector_store",
+ "name": "My workspace",
+ "file_counts": {
+ "total": 3
+ },
+ "provider": "LanceDB"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ // We dump all in the first response and despite saying there is
+ // not more data the library still checks with a query param so if
+ // we detect one - respond with nothing.
+ if (Object.keys(request?.query ?? {}).length !== 0) {
+ return response.status(200).json({
+ data: [],
+ has_more: false,
+ });
+ }
+
+ const data = [];
+ const VectorDBProvider = process.env.VECTOR_DB || "lancedb";
+ const workspaces = await Workspace.where();
+
+ for (const workspace of workspaces) {
+ data.push({
+ id: workspace.slug,
+ object: "vector_store",
+ name: workspace.name,
+ file_counts: {
+ total: await Document.count({
+ workspaceId: Number(workspace.id),
+ }),
+ },
+ provider: VectorDBProvider,
+ });
+ }
+ return response.status(200).json({
+ first_id: [...data].splice(0)?.[0]?.id,
+ last_id: [...data].splice(-1)?.[0]?.id ?? data.splice(1)?.[0]?.id,
+ data,
+ has_more: false,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.status(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { apiOpenAICompatibleEndpoints };
diff --git a/server/endpoints/api/system/index.js b/server/endpoints/api/system/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..a9c1a5e0820728ec9d2be2d12c9e52c86560e8cc
--- /dev/null
+++ b/server/endpoints/api/system/index.js
@@ -0,0 +1,275 @@
+const { EventLogs } = require("../../../models/eventLogs");
+const { SystemSettings } = require("../../../models/systemSettings");
+const { purgeDocument } = require("../../../utils/files/purgeDocument");
+const { getVectorDbClass } = require("../../../utils/helpers");
+const { exportChatsAsType } = require("../../../utils/helpers/chat/convertTo");
+const { dumpENV, updateENV } = require("../../../utils/helpers/updateENV");
+const { reqBody } = require("../../../utils/http");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+
+function apiSystemEndpoints(app) {
+ if (!app) return;
+
+ app.get("/v1/system/env-dump", async (_, response) => {
+ /*
+ #swagger.tags = ['System Settings']
+ #swagger.description = 'Dump all settings to file storage'
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ if (process.env.NODE_ENV !== "production")
+ return response.sendStatus(200).end();
+ dumpENV();
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get("/v1/system", [validApiKey], async (_, response) => {
+ /*
+ #swagger.tags = ['System Settings']
+ #swagger.description = 'Get all current system settings that are defined.'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "settings": {
+ "VectorDB": "pinecone",
+ "PineConeKey": true,
+ "PineConeIndex": "my-pinecone-index",
+ "LLMProvider": "azure",
+ "[KEY_NAME]": "KEY_VALUE",
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const settings = await SystemSettings.currentSettings();
+ response.status(200).json({ settings });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get("/v1/system/vector-count", [validApiKey], async (_, response) => {
+ /*
+ #swagger.tags = ['System Settings']
+ #swagger.description = 'Number of all vectors in connected vector database'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ "vectorCount": 5450
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const VectorDb = getVectorDbClass();
+ const vectorCount = await VectorDb.totalVectors();
+ response.status(200).json({ vectorCount });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post(
+ "/v1/system/update-env",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['System Settings']
+ #swagger.description = 'Update a system setting or preference.'
+ #swagger.requestBody = {
+ description: 'Key pair object that matches a valid setting and value. Get keys from GET /v1/system or refer to codebase.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ VectorDB: "lancedb",
+ AnotherKey: "updatedValue"
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ newValues: {"[ENV_KEY]": 'Value'},
+ error: 'error goes here, otherwise null'
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const body = reqBody(request);
+ const { newValues, error } = await updateENV(body);
+ response.status(200).json({ newValues, error });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/system/export-chats",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['System Settings']
+ #swagger.description = 'Export all of the chats from the system in a known format. Output depends on the type sent. Will be send with the correct header for the output.'
+ #swagger.parameters['type'] = {
+ in: 'query',
+ description: "Export format jsonl, json, csv, jsonAlpaca",
+ required: false,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: [
+ {
+ "role": "user",
+ "content": "What is AnythinglLM?"
+ },
+ {
+ "role": "assistant",
+ "content": "AnythingLLM is a knowledge graph and vector database management system built using NodeJS express server. It provides an interface for handling all interactions, including vectorDB management and LLM (Language Model) interactions."
+ },
+ ]
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { type = "jsonl" } = request.query;
+ const { contentType, data } = await exportChatsAsType(
+ type,
+ "workspace"
+ );
+ await EventLogs.logEvent("exported_chats", {
+ type,
+ });
+ response.setHeader("Content-Type", contentType);
+ response.status(200).send(data);
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+ app.delete(
+ "/v1/system/remove-documents",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['System Settings']
+ #swagger.description = 'Permanently remove documents from the system.'
+ #swagger.requestBody = {
+ description: 'Array of document names to be removed permanently.',
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ properties: {
+ names: {
+ type: 'array',
+ items: {
+ type: 'string'
+ },
+ example: [
+ "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ description: 'Documents removed successfully.',
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ success: true,
+ message: 'Documents removed successfully'
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ description: 'Forbidden',
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[500] = {
+ description: 'Internal Server Error'
+ }
+ */
+ try {
+ const { names } = reqBody(request);
+ for await (const name of names) await purgeDocument(name);
+ response
+ .status(200)
+ .json({ success: true, message: "Documents removed successfully" })
+ .end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { apiSystemEndpoints };
diff --git a/server/endpoints/api/userManagement/index.js b/server/endpoints/api/userManagement/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..733e1d3139aa366d4045b2ad64cb5f9002fa5432
--- /dev/null
+++ b/server/endpoints/api/userManagement/index.js
@@ -0,0 +1,124 @@
+const { User } = require("../../../models/user");
+const { TemporaryAuthToken } = require("../../../models/temporaryAuthToken");
+const { multiUserMode } = require("../../../utils/http");
+const {
+ simpleSSOEnabled,
+} = require("../../../utils/middleware/simpleSSOEnabled");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+
+function apiUserManagementEndpoints(app) {
+ if (!app) return;
+
+ app.get("/v1/users", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['User Management']
+ #swagger.description = 'List all users'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ users: [
+ {
+ "id": 1,
+ "username": "john_doe",
+ "role": "admin"
+ },
+ {
+ "id": 2,
+ "username": "jane_smith",
+ "role": "default"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Permission denied.",
+ }
+ */
+ try {
+ if (!multiUserMode(response))
+ return response
+ .status(401)
+ .send("Instance is not in Multi-User mode. Permission denied.");
+
+ const users = await User.where();
+ const filteredUsers = users.map((user) => ({
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ }));
+ response.status(200).json({ users: filteredUsers });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get(
+ "/v1/users/:id/issue-auth-token",
+ [validApiKey, simpleSSOEnabled],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['User Management']
+ #swagger.description = 'Issue a temporary auth token for a user'
+ #swagger.parameters['id'] = {
+ in: 'path',
+ description: 'The ID of the user to issue a temporary auth token for',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ token: "1234567890",
+ loginPath: "/sso/simple?token=1234567890"
+ }
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ #swagger.responses[401] = {
+ description: "Instance is not in Multi-User mode. Permission denied.",
+ }
+ */
+ try {
+ const { id: userId } = request.params;
+ const user = await User.get({ id: Number(userId) });
+ if (!user)
+ return response.status(404).json({ error: "User not found" });
+
+ const { token, error } = await TemporaryAuthToken.issue(userId);
+ if (error) return response.status(500).json({ error: error });
+
+ response.status(200).json({
+ token: String(token),
+ loginPath: `/sso/simple?token=${token}`,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { apiUserManagementEndpoints };
diff --git a/server/endpoints/api/workspace/index.js b/server/endpoints/api/workspace/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..c8e435f6f00e94833759984f0b265c36ece3e7c4
--- /dev/null
+++ b/server/endpoints/api/workspace/index.js
@@ -0,0 +1,1002 @@
+const { v4: uuidv4 } = require("uuid");
+const { Document } = require("../../../models/documents");
+const { Telemetry } = require("../../../models/telemetry");
+const { DocumentVectors } = require("../../../models/vectors");
+const { Workspace } = require("../../../models/workspace");
+const { WorkspaceChats } = require("../../../models/workspaceChats");
+const { getVectorDbClass, getLLMProvider } = require("../../../utils/helpers");
+const { multiUserMode, reqBody } = require("../../../utils/http");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+const { VALID_CHAT_MODE } = require("../../../utils/chats/stream");
+const { EventLogs } = require("../../../models/eventLogs");
+const {
+ convertToChatHistory,
+ writeResponseChunk,
+} = require("../../../utils/helpers/chat/responses");
+const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler");
+const { getModelTag } = require("../../utils");
+
+function apiWorkspaceEndpoints(app) {
+ if (!app) return;
+
+ app.post("/v1/workspace/new", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Create a new workspace'
+ #swagger.requestBody = {
+ description: 'JSON object containing workspace configuration.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ name: "My New Workspace",
+ similarityThreshold: 0.7,
+ openAiTemp: 0.7,
+ openAiHistory: 20,
+ openAiPrompt: "Custom prompt for responses",
+ queryRefusalResponse: "Custom refusal message",
+ chatMode: "chat",
+ topN: 4
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ workspace: {
+ "id": 79,
+ "name": "Sample workspace",
+ "slug": "sample-workspace",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null
+ },
+ message: 'Workspace created'
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { name = null, ...additionalFields } = reqBody(request);
+ const { workspace, message } = await Workspace.new(
+ name,
+ null,
+ additionalFields
+ );
+
+ if (!workspace) {
+ response.status(400).json({ workspace: null, message });
+ return;
+ }
+
+ await Telemetry.sendTelemetry("workspace_created", {
+ multiUserMode: multiUserMode(response),
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ });
+ await EventLogs.logEvent("api_workspace_created", {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ });
+ response.status(200).json({ workspace, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get("/v1/workspaces", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'List all current workspaces'
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ workspaces: [
+ {
+ "id": 79,
+ "name": "Sample workspace",
+ "slug": "sample-workspace",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "threads": []
+ }
+ ],
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const workspaces = await Workspace._findMany({
+ where: {},
+ include: {
+ threads: {
+ select: {
+ user_id: true,
+ slug: true,
+ name: true,
+ },
+ },
+ },
+ });
+ response.status(200).json({ workspaces });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get("/v1/workspace/:slug", [validApiKey], async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Get a workspace by its unique slug.'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace to find',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ workspace: [
+ {
+ "id": 79,
+ "name": "My workspace",
+ "slug": "my-workspace-123",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "documents": [],
+ "threads": []
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug } = request.params;
+ const workspace = await Workspace._findMany({
+ where: {
+ slug: String(slug),
+ },
+ include: {
+ documents: true,
+ threads: {
+ select: {
+ user_id: true,
+ slug: true,
+ },
+ },
+ },
+ });
+
+ response.status(200).json({ workspace });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.delete(
+ "/v1/workspace/:slug",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Deletes a workspace by its slug.'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace to delete',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug = "" } = request.params;
+ const VectorDb = getVectorDbClass();
+ const workspace = await Workspace.get({ slug });
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const workspaceId = Number(workspace.id);
+ await WorkspaceChats.delete({ workspaceId: workspaceId });
+ await DocumentVectors.deleteForWorkspace(workspaceId);
+ await Document.delete({ workspaceId: workspaceId });
+ await Workspace.delete({ id: workspaceId });
+
+ await EventLogs.logEvent("api_workspace_deleted", {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ });
+ try {
+ await VectorDb["delete-namespace"]({ namespace: slug });
+ } catch (e) {
+ console.error(e.message);
+ }
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/update",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Update workspace settings by its unique slug.'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace to find',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'JSON object containing new settings to update a workspace. All keys are optional and will not update unless provided',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ "name": 'Updated Workspace Name',
+ "openAiTemp": 0.2,
+ "openAiHistory": 20,
+ "openAiPrompt": "Respond to all inquires and questions in binary - do not respond in any other format."
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ workspace: {
+ "id": 79,
+ "name": "My workspace",
+ "slug": "my-workspace-123",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "documents": []
+ },
+ message: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug = null } = request.params;
+ const data = reqBody(request);
+ const currWorkspace = await Workspace.get({ slug });
+
+ if (!currWorkspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const { workspace, message } = await Workspace.update(
+ currWorkspace.id,
+ data
+ );
+ response.status(200).json({ workspace, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/workspace/:slug/chats",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Get a workspaces chats regardless of user by its unique slug.'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace to find',
+ required: true,
+ type: 'string'
+ }
+ #swagger.parameters['apiSessionId'] = {
+ in: 'query',
+ description: 'Optional apiSessionId to filter by',
+ required: false,
+ type: 'string'
+ }
+ #swagger.parameters['limit'] = {
+ in: 'query',
+ description: 'Optional number of chat messages to return (default: 100)',
+ required: false,
+ type: 'integer'
+ }
+ #swagger.parameters['orderBy'] = {
+ in: 'query',
+ description: 'Optional order of chat messages (asc or desc)',
+ required: false,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ history: [
+ {
+ "role": "user",
+ "content": "What is AnythingLLM?",
+ "sentAt": 1692851630
+ },
+ {
+ "role": "assistant",
+ "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.",
+ "sources": [{"source": "object about source document and snippets used"}]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug } = request.params;
+ const {
+ apiSessionId = null,
+ limit = 100,
+ orderBy = "asc",
+ } = request.query;
+ const workspace = await Workspace.get({ slug });
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const validLimit = Math.max(1, parseInt(limit));
+ const validOrderBy = ["asc", "desc"].includes(orderBy)
+ ? orderBy
+ : "asc";
+
+ const history = apiSessionId
+ ? await WorkspaceChats.forWorkspaceByApiSessionId(
+ workspace.id,
+ apiSessionId,
+ validLimit,
+ { createdAt: validOrderBy }
+ )
+ : await WorkspaceChats.forWorkspace(workspace.id, validLimit, {
+ createdAt: validOrderBy,
+ });
+ response.status(200).json({ history: convertToChatHistory(history) });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/update-embeddings",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Add or remove documents from a workspace by its unique slug.'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace to find',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'JSON object of additions and removals of documents to add to update a workspace. The value should be the folder + filename with the exclusions of the top-level documents path.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ adds: ["custom-documents/my-pdf.pdf-hash.json"],
+ deletes: ["custom-documents/anythingllm.txt-hash.json"]
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ workspace: {
+ "id": 79,
+ "name": "My workspace",
+ "slug": "my-workspace-123",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "documents": []
+ },
+ message: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug = null } = request.params;
+ const { adds = [], deletes = [] } = reqBody(request);
+ const currWorkspace = await Workspace.get({ slug });
+
+ if (!currWorkspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ await Document.removeDocuments(currWorkspace, deletes);
+ await Document.addDocuments(currWorkspace, adds);
+ const updatedWorkspace = await Workspace.get({
+ id: Number(currWorkspace.id),
+ });
+ response.status(200).json({ workspace: updatedWorkspace });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/update-pin",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Add or remove pin from a document in a workspace by its unique slug.'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace to find',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'JSON object with the document path and pin status to update.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ docPath: "custom-documents/my-pdf.pdf-hash.json",
+ pinStatus: true
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ description: 'OK',
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ message: 'Pin status updated successfully'
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[404] = {
+ description: 'Document not found'
+ }
+ #swagger.responses[500] = {
+ description: 'Internal Server Error'
+ }
+ */
+ try {
+ const { slug = null } = request.params;
+ const { docPath, pinStatus = false } = reqBody(request);
+ const workspace = await Workspace.get({ slug });
+
+ const document = await Document.get({
+ workspaceId: workspace.id,
+ docpath: docPath,
+ });
+ if (!document) return response.sendStatus(404).end();
+
+ await Document.update(document.id, { pinned: pinStatus });
+ return response
+ .status(200)
+ .json({ message: "Pin status updated successfully" })
+ .end();
+ } catch (error) {
+ console.error("Error processing the pin status update:", error);
+ return response.status(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/chat",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Execute a chat with a workspace'
+ #swagger.requestBody = {
+ description: 'Send a prompt to the workspace and the type of conversation (query or chat).Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ message: "What is AnythingLLM?",
+ mode: "query | chat",
+ sessionId: "identifier-to-partition-chats-by-external-id",
+ attachments: [
+ {
+ name: "image.png",
+ mime: "image/png",
+ contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ reset: false
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ id: 'chat-uuid',
+ type: "abort | textResponse",
+ textResponse: "Response to your query",
+ sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM,"}],
+ close: true,
+ error: "null | text string of the failure mode."
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug } = request.params;
+ const {
+ message,
+ mode = "query",
+ sessionId = null,
+ attachments = [],
+ reset = false,
+ } = reqBody(request);
+ const workspace = await Workspace.get({ slug: String(slug) });
+
+ if (!workspace) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: `Workspace ${slug} is not a valid workspace.`,
+ });
+ return;
+ }
+
+ if ((!message?.length || !VALID_CHAT_MODE.includes(mode)) && !reset) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: !message?.length
+ ? "Message is empty"
+ : `${mode} is not a valid mode.`,
+ });
+ return;
+ }
+
+ const result = await ApiChatHandler.chatSync({
+ workspace,
+ message,
+ mode,
+ user: null,
+ thread: null,
+ sessionId: !!sessionId ? String(sessionId) : null,
+ attachments,
+ reset,
+ });
+
+ await Telemetry.sendTelemetry("sent_chat", {
+ LLMSelection:
+ workspace.chatProvider ?? process.env.LLM_PROVIDER ?? "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ });
+ await EventLogs.logEvent("api_sent_chat", {
+ workspaceName: workspace?.name,
+ chatModel: workspace?.chatModel || "System Default",
+ });
+ return response.status(200).json({ ...result });
+ } catch (e) {
+ console.error(e.message, e);
+ response.status(500).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/stream-chat",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Execute a streamable chat with a workspace'
+ #swagger.requestBody = {
+ description: 'Send a prompt to the workspace and the type of conversation (query or chat).Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ message: "What is AnythingLLM?",
+ mode: "query | chat",
+ sessionId: "identifier-to-partition-chats-by-external-id",
+ attachments: [
+ {
+ name: "image.png",
+ mime: "image/png",
+ contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ reset: false
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "text/event-stream": {
+ schema: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ example: [
+ {
+ id: 'uuid-123',
+ type: "abort | textResponseChunk",
+ textResponse: "First chunk",
+ sources: [],
+ close: false,
+ error: "null | text string of the failure mode."
+ },
+ {
+ id: 'uuid-123',
+ type: "abort | textResponseChunk",
+ textResponse: "chunk two",
+ sources: [],
+ close: false,
+ error: "null | text string of the failure mode."
+ },
+ {
+ id: 'uuid-123',
+ type: "abort | textResponseChunk",
+ textResponse: "final chunk of LLM output!",
+ sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk."}],
+ close: true,
+ error: "null | text string of the failure mode."
+ }
+ ]
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug } = request.params;
+ const {
+ message,
+ mode = "query",
+ sessionId = null,
+ attachments = [],
+ reset = false,
+ } = reqBody(request);
+ const workspace = await Workspace.get({ slug: String(slug) });
+
+ if (!workspace) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: `Workspace ${slug} is not a valid workspace.`,
+ });
+ return;
+ }
+
+ if ((!message?.length || !VALID_CHAT_MODE.includes(mode)) && !reset) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: !message?.length
+ ? "Message is empty"
+ : `${mode} is not a valid mode.`,
+ });
+ return;
+ }
+
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Content-Type", "text/event-stream");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Connection", "keep-alive");
+ response.flushHeaders();
+
+ await ApiChatHandler.streamChat({
+ response,
+ workspace,
+ message,
+ mode,
+ user: null,
+ thread: null,
+ sessionId: !!sessionId ? String(sessionId) : null,
+ attachments,
+ reset,
+ });
+ await Telemetry.sendTelemetry("sent_chat", {
+ LLMSelection:
+ workspace.chatProvider ?? process.env.LLM_PROVIDER ?? "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ });
+ await EventLogs.logEvent("api_sent_chat", {
+ workspaceName: workspace?.name,
+ chatModel: workspace?.chatModel || "System Default",
+ });
+ response.end();
+ } catch (e) {
+ console.error(e.message, e);
+ writeResponseChunk(response, {
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ response.end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/vector-search",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspaces']
+ #swagger.description = 'Perform a vector similarity search in a workspace'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace to search in',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'Query to perform vector search with and optional parameters',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ query: "What is the meaning of life?",
+ topN: 4,
+ scoreThreshold: 0.75
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ results: [
+ {
+ id: "5a6bee0a-306c-47fc-942b-8ab9bf3899c4",
+ text: "Document chunk content...",
+ metadata: {
+ url: "file://document.txt",
+ title: "document.txt",
+ author: "no author specified",
+ description: "no description found",
+ docSource: "post:123456",
+ chunkSource: "document.txt",
+ published: "12/1/2024, 11:39:39 AM",
+ wordCount: 8,
+ tokenCount: 9
+ },
+ distance: 0.541887640953064,
+ score: 0.45811235904693604
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ */
+ try {
+ const { slug } = request.params;
+ const { query, topN, scoreThreshold } = reqBody(request);
+ const workspace = await Workspace.get({ slug: String(slug) });
+
+ if (!workspace)
+ return response.status(400).json({
+ message: `Workspace ${slug} is not a valid workspace.`,
+ });
+
+ if (!query?.length)
+ return response.status(400).json({
+ message: "Query parameter cannot be empty.",
+ });
+
+ const VectorDb = getVectorDbClass();
+ const hasVectorizedSpace = await VectorDb.hasNamespace(workspace.slug);
+ const embeddingsCount = await VectorDb.namespaceCount(workspace.slug);
+
+ if (!hasVectorizedSpace || embeddingsCount === 0)
+ return response.status(200).json({
+ results: [],
+ message: "No embeddings found for this workspace.",
+ });
+
+ const parseSimilarityThreshold = () => {
+ let input = parseFloat(scoreThreshold);
+ if (isNaN(input) || input < 0 || input > 1)
+ return workspace?.similarityThreshold ?? 0.25;
+ return input;
+ };
+
+ const parseTopN = () => {
+ let input = Number(topN);
+ if (isNaN(input) || input < 1) return workspace?.topN ?? 4;
+ return input;
+ };
+
+ const results = await VectorDb.performSimilaritySearch({
+ namespace: workspace.slug,
+ input: String(query),
+ LLMConnector: getLLMProvider(),
+ similarityThreshold: parseSimilarityThreshold(),
+ topN: parseTopN(),
+ rerank: workspace?.vectorSearchMode === "rerank",
+ });
+
+ response.status(200).json({
+ results: results.sources.map((source) => ({
+ id: source.id,
+ text: source.text,
+ metadata: {
+ url: source.url,
+ title: source.title,
+ author: source.docAuthor,
+ description: source.description,
+ docSource: source.docSource,
+ chunkSource: source.chunkSource,
+ published: source.published,
+ wordCount: source.wordCount,
+ tokenCount: source.token_count_estimate,
+ },
+ distance: source._distance,
+ score: source.score,
+ })),
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { apiWorkspaceEndpoints };
diff --git a/server/endpoints/api/workspaceThread/index.js b/server/endpoints/api/workspaceThread/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..9a85b346a82eef10e8ca7464eddaec981af2394b
--- /dev/null
+++ b/server/endpoints/api/workspaceThread/index.js
@@ -0,0 +1,636 @@
+const { v4: uuidv4 } = require("uuid");
+const { WorkspaceThread } = require("../../../models/workspaceThread");
+const { Workspace } = require("../../../models/workspace");
+const { validApiKey } = require("../../../utils/middleware/validApiKey");
+const { reqBody, multiUserMode } = require("../../../utils/http");
+const { VALID_CHAT_MODE } = require("../../../utils/chats/stream");
+const { Telemetry } = require("../../../models/telemetry");
+const { EventLogs } = require("../../../models/eventLogs");
+const {
+ writeResponseChunk,
+ convertToChatHistory,
+} = require("../../../utils/helpers/chat/responses");
+const { WorkspaceChats } = require("../../../models/workspaceChats");
+const { User } = require("../../../models/user");
+const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler");
+const { getModelTag } = require("../../utils");
+
+function apiWorkspaceThreadEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/v1/workspace/:slug/thread/new",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspace Threads']
+ #swagger.description = 'Create a new workspace thread'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'Optional userId associated with the thread, thread slug and thread name',
+ required: false,
+ content: {
+ "application/json": {
+ example: {
+ userId: 1,
+ name: 'Name',
+ slug: 'thread-slug'
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ thread: {
+ "id": 1,
+ "name": "Thread",
+ "slug": "thread-uuid",
+ "user_id": 1,
+ "workspace_id": 1
+ },
+ message: null
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const wslug = request.params.slug;
+ let { userId = null, name = null, slug = null } = reqBody(request);
+ const workspace = await Workspace.get({ slug: wslug });
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ // If the system is not multi-user and you pass in a userId
+ // it needs to be nullified as no users exist. This can still fail validation
+ // as we don't check if the userID is valid.
+ if (!response.locals.multiUserMode && !!userId) userId = null;
+
+ const { thread, message } = await WorkspaceThread.new(
+ workspace,
+ userId ? Number(userId) : null,
+ { name, slug }
+ );
+
+ await Telemetry.sendTelemetry("workspace_thread_created", {
+ multiUserMode: multiUserMode(response),
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ });
+ await EventLogs.logEvent("api_workspace_thread_created", {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ });
+ response.status(200).json({ thread, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/thread/:threadSlug/update",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspace Threads']
+ #swagger.description = 'Update thread name by its unique slug.'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace',
+ required: true,
+ type: 'string'
+ }
+ #swagger.parameters['threadSlug'] = {
+ in: 'path',
+ description: 'Unique slug of thread',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'JSON object containing new name to update the thread.',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ "name": 'Updated Thread Name'
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ thread: {
+ "id": 1,
+ "name": "Updated Thread Name",
+ "slug": "thread-uuid",
+ "user_id": 1,
+ "workspace_id": 1
+ },
+ message: null,
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug, threadSlug } = request.params;
+ const { name } = reqBody(request);
+ const workspace = await Workspace.get({ slug });
+ const thread = await WorkspaceThread.get({
+ slug: threadSlug,
+ workspace_id: workspace.id,
+ });
+
+ if (!workspace || !thread) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const { thread: updatedThread, message } = await WorkspaceThread.update(
+ thread,
+ { name }
+ );
+ response.status(200).json({ thread: updatedThread, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/v1/workspace/:slug/thread/:threadSlug",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspace Threads']
+ #swagger.description = 'Delete a workspace thread'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace',
+ required: true,
+ type: 'string'
+ }
+ #swagger.parameters['threadSlug'] = {
+ in: 'path',
+ description: 'Unique slug of thread',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ description: 'Thread deleted successfully'
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug, threadSlug } = request.params;
+ const workspace = await Workspace.get({ slug });
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ await WorkspaceThread.delete({
+ slug: threadSlug,
+ workspace_id: workspace.id,
+ });
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/v1/workspace/:slug/thread/:threadSlug/chats",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspace Threads']
+ #swagger.description = 'Get chats for a workspace thread'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace',
+ required: true,
+ type: 'string'
+ }
+ #swagger.parameters['threadSlug'] = {
+ in: 'path',
+ description: 'Unique slug of thread',
+ required: true,
+ type: 'string'
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ history: [
+ {
+ "role": "user",
+ "content": "What is AnythingLLM?",
+ "sentAt": 1692851630
+ },
+ {
+ "role": "assistant",
+ "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.",
+ "sources": [{"source": "object about source document and snippets used"}]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug, threadSlug } = request.params;
+ const workspace = await Workspace.get({ slug });
+ const thread = await WorkspaceThread.get({
+ slug: threadSlug,
+ workspace_id: workspace.id,
+ });
+
+ if (!workspace || !thread) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const history = await WorkspaceChats.where(
+ {
+ workspaceId: workspace.id,
+ thread_id: thread.id,
+ api_session_id: null, // Do not include API session chats.
+ include: true,
+ },
+ null,
+ { id: "asc" }
+ );
+
+ response.status(200).json({ history: convertToChatHistory(history) });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/thread/:threadSlug/chat",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspace Threads']
+ #swagger.description = 'Chat with a workspace thread'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace',
+ required: true,
+ type: 'string'
+ }
+ #swagger.parameters['threadSlug'] = {
+ in: 'path',
+ description: 'Unique slug of thread',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'Send a prompt to the workspace thread and the type of conversation (query or chat).',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ message: "What is AnythingLLM?",
+ mode: "query | chat",
+ userId: 1,
+ attachments: [
+ {
+ name: "image.png",
+ mime: "image/png",
+ contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ reset: false
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "application/json": {
+ schema: {
+ type: 'object',
+ example: {
+ id: 'chat-uuid',
+ type: "abort | textResponse",
+ textResponse: "Response to your query",
+ sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM."}],
+ close: true,
+ error: "null | text string of the failure mode."
+ }
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug, threadSlug } = request.params;
+ const {
+ message,
+ mode = "query",
+ userId,
+ attachments = [],
+ reset = false,
+ } = reqBody(request);
+ const workspace = await Workspace.get({ slug });
+ const thread = await WorkspaceThread.get({
+ slug: threadSlug,
+ workspace_id: workspace.id,
+ });
+
+ if (!workspace || !thread) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: `Workspace ${slug} or thread ${threadSlug} is not valid.`,
+ });
+ return;
+ }
+
+ if ((!message?.length || !VALID_CHAT_MODE.includes(mode)) && !reset) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: !message?.length
+ ? "Message is empty"
+ : `${mode} is not a valid mode.`,
+ });
+ return;
+ }
+
+ const user = userId ? await User.get({ id: Number(userId) }) : null;
+ const result = await ApiChatHandler.chatSync({
+ workspace,
+ message,
+ mode,
+ user,
+ thread,
+ attachments,
+ reset,
+ });
+ await Telemetry.sendTelemetry("sent_chat", {
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ });
+ await EventLogs.logEvent("api_sent_chat", {
+ workspaceName: workspace?.name,
+ chatModel: workspace?.chatModel || "System Default",
+ threadName: thread?.name,
+ userId: user?.id,
+ });
+ response.status(200).json({ ...result });
+ } catch (e) {
+ console.error(e.message, e);
+ response.status(500).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/v1/workspace/:slug/thread/:threadSlug/stream-chat",
+ [validApiKey],
+ async (request, response) => {
+ /*
+ #swagger.tags = ['Workspace Threads']
+ #swagger.description = 'Stream chat with a workspace thread'
+ #swagger.parameters['slug'] = {
+ in: 'path',
+ description: 'Unique slug of workspace',
+ required: true,
+ type: 'string'
+ }
+ #swagger.parameters['threadSlug'] = {
+ in: 'path',
+ description: 'Unique slug of thread',
+ required: true,
+ type: 'string'
+ }
+ #swagger.requestBody = {
+ description: 'Send a prompt to the workspace thread and the type of conversation (query or chat).',
+ required: true,
+ content: {
+ "application/json": {
+ example: {
+ message: "What is AnythingLLM?",
+ mode: "query | chat",
+ userId: 1,
+ attachments: [
+ {
+ name: "image.png",
+ mime: "image/png",
+ contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ reset: false
+ }
+ }
+ }
+ }
+ #swagger.responses[200] = {
+ content: {
+ "text/event-stream": {
+ schema: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ example: [
+ {
+ id: 'uuid-123',
+ type: "abort | textResponseChunk",
+ textResponse: "First chunk",
+ sources: [],
+ close: false,
+ error: "null | text string of the failure mode."
+ },
+ {
+ id: 'uuid-123',
+ type: "abort | textResponseChunk",
+ textResponse: "chunk two",
+ sources: [],
+ close: false,
+ error: "null | text string of the failure mode."
+ },
+ {
+ id: 'uuid-123',
+ type: "abort | textResponseChunk",
+ textResponse: "final chunk of LLM output!",
+ sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk."}],
+ close: true,
+ error: "null | text string of the failure mode."
+ }
+ ]
+ }
+ }
+ }
+ }
+ #swagger.responses[403] = {
+ schema: {
+ "$ref": "#/definitions/InvalidAPIKey"
+ }
+ }
+ */
+ try {
+ const { slug, threadSlug } = request.params;
+ const {
+ message,
+ mode = "query",
+ userId,
+ attachments = [],
+ reset = false,
+ } = reqBody(request);
+ const workspace = await Workspace.get({ slug });
+ const thread = await WorkspaceThread.get({
+ slug: threadSlug,
+ workspace_id: workspace.id,
+ });
+
+ if (!workspace || !thread) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: `Workspace ${slug} or thread ${threadSlug} is not valid.`,
+ });
+ return;
+ }
+
+ if ((!message?.length || !VALID_CHAT_MODE.includes(mode)) && !reset) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: !message?.length
+ ? "Message is empty"
+ : `${mode} is not a valid mode.`,
+ });
+ return;
+ }
+
+ const user = userId ? await User.get({ id: Number(userId) }) : null;
+
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Content-Type", "text/event-stream");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Connection", "keep-alive");
+ response.flushHeaders();
+
+ await ApiChatHandler.streamChat({
+ response,
+ workspace,
+ message,
+ mode,
+ user,
+ thread,
+ attachments,
+ reset,
+ });
+ await Telemetry.sendTelemetry("sent_chat", {
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ });
+ await EventLogs.logEvent("api_sent_chat", {
+ workspaceName: workspace?.name,
+ chatModel: workspace?.chatModel || "System Default",
+ threadName: thread?.name,
+ userId: user?.id,
+ });
+ response.end();
+ } catch (e) {
+ console.error(e.message, e);
+ writeResponseChunk(response, {
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ response.end();
+ }
+ }
+ );
+}
+
+module.exports = { apiWorkspaceThreadEndpoints };
diff --git a/server/endpoints/browserExtension.js b/server/endpoints/browserExtension.js
new file mode 100644
index 0000000000000000000000000000000000000000..844da00e760cb3d3555ec14c32190eefc279f120
--- /dev/null
+++ b/server/endpoints/browserExtension.js
@@ -0,0 +1,224 @@
+const { Workspace } = require("../models/workspace");
+const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
+const { Document } = require("../models/documents");
+const {
+ validBrowserExtensionApiKey,
+} = require("../utils/middleware/validBrowserExtensionApiKey");
+const { CollectorApi } = require("../utils/collectorApi");
+const { reqBody, multiUserMode, userFromSession } = require("../utils/http");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { Telemetry } = require("../models/telemetry");
+
+function browserExtensionEndpoints(app) {
+ if (!app) return;
+
+ app.get(
+ "/browser-extension/check",
+ [validBrowserExtensionApiKey],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const workspaces = multiUserMode(response)
+ ? await Workspace.whereWithUser(user)
+ : await Workspace.where();
+
+ const apiKeyId = response.locals.apiKey.id;
+ response.status(200).json({
+ connected: true,
+ workspaces,
+ apiKeyId,
+ });
+ } catch (error) {
+ console.error(error);
+ response
+ .status(500)
+ .json({ connected: false, error: "Failed to fetch workspaces" });
+ }
+ }
+ );
+
+ app.delete(
+ "/browser-extension/disconnect",
+ [validBrowserExtensionApiKey],
+ async (_request, response) => {
+ try {
+ const apiKeyId = response.locals.apiKey.id;
+ const { success, error } =
+ await BrowserExtensionApiKey.delete(apiKeyId);
+ if (!success) throw new Error(error);
+ response.status(200).json({ success: true });
+ } catch (error) {
+ console.error(error);
+ response
+ .status(500)
+ .json({ error: "Failed to disconnect and revoke API key" });
+ }
+ }
+ );
+
+ app.get(
+ "/browser-extension/workspaces",
+ [validBrowserExtensionApiKey],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const workspaces = multiUserMode(response)
+ ? await Workspace.whereWithUser(user)
+ : await Workspace.where();
+
+ response.status(200).json({ workspaces });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ error: "Failed to fetch workspaces" });
+ }
+ }
+ );
+
+ app.post(
+ "/browser-extension/embed-content",
+ [validBrowserExtensionApiKey],
+ async (request, response) => {
+ try {
+ const { workspaceId, textContent, metadata } = reqBody(request);
+ const user = await userFromSession(request, response);
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { id: parseInt(workspaceId) })
+ : await Workspace.get({ id: parseInt(workspaceId) });
+
+ if (!workspace) {
+ response.status(404).json({ error: "Workspace not found" });
+ return;
+ }
+
+ const Collector = new CollectorApi();
+ const { success, reason, documents } = await Collector.processRawText(
+ textContent,
+ metadata
+ );
+
+ if (!success) {
+ response.status(500).json({ success: false, error: reason });
+ return;
+ }
+
+ const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
+ workspace,
+ [documents[0].location],
+ user?.id
+ );
+
+ if (failedToEmbed.length > 0) {
+ response.status(500).json({ success: false, error: errors[0] });
+ return;
+ }
+
+ await Telemetry.sendTelemetry("browser_extension_embed_content");
+ response.status(200).json({ success: true });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ error: "Failed to embed content" });
+ }
+ }
+ );
+
+ app.post(
+ "/browser-extension/upload-content",
+ [validBrowserExtensionApiKey],
+ async (request, response) => {
+ try {
+ const { textContent, metadata } = reqBody(request);
+ const Collector = new CollectorApi();
+ const { success, reason } = await Collector.processRawText(
+ textContent,
+ metadata
+ );
+
+ if (!success) {
+ response.status(500).json({ success: false, error: reason });
+ return;
+ }
+
+ await Telemetry.sendTelemetry("browser_extension_upload_content");
+ response.status(200).json({ success: true });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ error: "Failed to embed content" });
+ }
+ }
+ );
+
+ // Internal endpoints for managing API keys
+ app.get(
+ "/browser-extension/api-keys",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const apiKeys = multiUserMode(response)
+ ? await BrowserExtensionApiKey.whereWithUser(user)
+ : await BrowserExtensionApiKey.where();
+
+ response.status(200).json({ success: true, apiKeys });
+ } catch (error) {
+ console.error(error);
+ response
+ .status(500)
+ .json({ success: false, error: "Failed to fetch API keys" });
+ }
+ }
+ );
+
+ app.post(
+ "/browser-extension/api-keys/new",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { apiKey, error } = await BrowserExtensionApiKey.create(
+ user?.id || null
+ );
+ if (error) throw new Error(error);
+ response.status(200).json({
+ apiKey: apiKey.key,
+ });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ error: "Failed to create API key" });
+ }
+ }
+ );
+
+ app.delete(
+ "/browser-extension/api-keys/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ const user = await userFromSession(request, response);
+
+ if (multiUserMode(response) && user.role !== ROLES.admin) {
+ const apiKey = await BrowserExtensionApiKey.get({
+ id: parseInt(id),
+ user_id: user?.id,
+ });
+ if (!apiKey) {
+ return response.status(403).json({ error: "Unauthorized" });
+ }
+ }
+
+ const { success, error } = await BrowserExtensionApiKey.delete(id);
+ if (!success) throw new Error(error);
+ response.status(200).json({ success: true });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ error: "Failed to revoke API key" });
+ }
+ }
+ );
+}
+
+module.exports = { browserExtensionEndpoints };
diff --git a/server/endpoints/chat.js b/server/endpoints/chat.js
new file mode 100644
index 0000000000000000000000000000000000000000..c8770857ea262ce0f2db7eee8a0cb3e5a7edec11
--- /dev/null
+++ b/server/endpoints/chat.js
@@ -0,0 +1,213 @@
+const { v4: uuidv4 } = require("uuid");
+const { reqBody, userFromSession, multiUserMode } = require("../utils/http");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { Telemetry } = require("../models/telemetry");
+const { streamChatWithWorkspace } = require("../utils/chats/stream");
+const {
+ ROLES,
+ flexUserRoleValid,
+} = require("../utils/middleware/multiUserProtected");
+const { EventLogs } = require("../models/eventLogs");
+const {
+ validWorkspaceAndThreadSlug,
+ validWorkspaceSlug,
+} = require("../utils/middleware/validWorkspace");
+const { writeResponseChunk } = require("../utils/helpers/chat/responses");
+const { WorkspaceThread } = require("../models/workspaceThread");
+const { User } = require("../models/user");
+const truncate = require("truncate");
+const { getModelTag } = require("./utils");
+
+function chatEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/workspace/:slug/stream-chat",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { message, attachments = [] } = reqBody(request);
+ const workspace = response.locals.workspace;
+
+ if (!message?.length) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: !message?.length ? "Message is empty." : null,
+ });
+ return;
+ }
+
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Content-Type", "text/event-stream");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Connection", "keep-alive");
+ response.flushHeaders();
+
+ if (multiUserMode(response) && !(await User.canSendChat(user))) {
+ writeResponseChunk(response, {
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: `You have met your maximum 24 hour chat quota of ${user.dailyMessageLimit} chats. Try again later.`,
+ });
+ return;
+ }
+
+ await streamChatWithWorkspace(
+ response,
+ workspace,
+ message,
+ workspace?.chatMode,
+ user,
+ null,
+ attachments
+ );
+ await Telemetry.sendTelemetry("sent_chat", {
+ multiUserMode: multiUserMode(response),
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ multiModal: Array.isArray(attachments) && attachments?.length !== 0,
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ });
+
+ await EventLogs.logEvent(
+ "sent_chat",
+ {
+ workspaceName: workspace?.name,
+ chatModel: workspace?.chatModel || "System Default",
+ },
+ user?.id
+ );
+ response.end();
+ } catch (e) {
+ console.error(e);
+ writeResponseChunk(response, {
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ response.end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/thread/:threadSlug/stream-chat",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.all]),
+ validWorkspaceAndThreadSlug,
+ ],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { message, attachments = [] } = reqBody(request);
+ const workspace = response.locals.workspace;
+ const thread = response.locals.thread;
+
+ if (!message?.length) {
+ response.status(400).json({
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: !message?.length ? "Message is empty." : null,
+ });
+ return;
+ }
+
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Content-Type", "text/event-stream");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Connection", "keep-alive");
+ response.flushHeaders();
+
+ if (multiUserMode(response) && !(await User.canSendChat(user))) {
+ writeResponseChunk(response, {
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: `You have met your maximum 24 hour chat quota of ${user.dailyMessageLimit} chats. Try again later.`,
+ });
+ return;
+ }
+
+ await streamChatWithWorkspace(
+ response,
+ workspace,
+ message,
+ workspace?.chatMode,
+ user,
+ thread,
+ attachments
+ );
+
+ // If thread was renamed emit event to frontend via special `action` response.
+ await WorkspaceThread.autoRenameThread({
+ thread,
+ workspace,
+ user,
+ newName: truncate(message, 22),
+ onRename: (thread) => {
+ writeResponseChunk(response, {
+ action: "rename_thread",
+ thread: {
+ slug: thread.slug,
+ name: thread.name,
+ },
+ });
+ },
+ });
+
+ await Telemetry.sendTelemetry("sent_chat", {
+ multiUserMode: multiUserMode(response),
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ multiModal: Array.isArray(attachments) && attachments?.length !== 0,
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ });
+
+ await EventLogs.logEvent(
+ "sent_chat",
+ {
+ workspaceName: workspace.name,
+ thread: thread.name,
+ chatModel: workspace?.chatModel || "System Default",
+ },
+ user?.id
+ );
+ response.end();
+ } catch (e) {
+ console.error(e);
+ writeResponseChunk(response, {
+ id: uuidv4(),
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ response.end();
+ }
+ }
+ );
+}
+
+module.exports = { chatEndpoints };
diff --git a/server/endpoints/communityHub.js b/server/endpoints/communityHub.js
new file mode 100644
index 0000000000000000000000000000000000000000..241434a69ab30eaa1d3e1dbda332432555b5c44d
--- /dev/null
+++ b/server/endpoints/communityHub.js
@@ -0,0 +1,219 @@
+const { SystemSettings } = require("../models/systemSettings");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { reqBody } = require("../utils/http");
+const { CommunityHub } = require("../models/communityHub");
+const {
+ communityHubDownloadsEnabled,
+ communityHubItem,
+} = require("../utils/middleware/communityHubDownloadsEnabled");
+const { EventLogs } = require("../models/eventLogs");
+const { Telemetry } = require("../models/telemetry");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+
+function communityHubEndpoints(app) {
+ if (!app) return;
+
+ app.get(
+ "/community-hub/settings",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_, response) => {
+ try {
+ const { connectionKey } = await SystemSettings.hubSettings();
+ response.status(200).json({ success: true, connectionKey });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ success: false, error: error.message });
+ }
+ }
+ );
+
+ app.post(
+ "/community-hub/settings",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const data = reqBody(request);
+ const result = await SystemSettings.updateSettings(data);
+ if (result.error) throw new Error(result.error);
+ response.status(200).json({ success: true, error: null });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ success: false, error: error.message });
+ }
+ }
+ );
+
+ app.get(
+ "/community-hub/explore",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_, response) => {
+ try {
+ const exploreItems = await CommunityHub.fetchExploreItems();
+ response.status(200).json({ success: true, result: exploreItems });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({
+ success: false,
+ result: null,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/community-hub/item",
+ [validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem],
+ async (_request, response) => {
+ try {
+ response.status(200).json({
+ success: true,
+ item: response.locals.bundleItem,
+ error: null,
+ });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({
+ success: false,
+ item: null,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ /**
+ * Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
+ */
+ app.post(
+ "/community-hub/apply",
+ [validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem],
+ async (request, response) => {
+ try {
+ const { options = {} } = reqBody(request);
+ const item = response.locals.bundleItem;
+ const { error: applyError } = await CommunityHub.applyItem(item, {
+ ...options,
+ currentUser: response.locals?.user,
+ });
+ if (applyError) throw new Error(applyError);
+
+ await Telemetry.sendTelemetry("community_hub_import", {
+ itemType: response.locals.bundleItem.itemType,
+ visibility: response.locals.bundleItem.visibility,
+ });
+ await EventLogs.logEvent(
+ "community_hub_import",
+ {
+ itemId: response.locals.bundleItem.id,
+ itemType: response.locals.bundleItem.itemType,
+ },
+ response.locals?.user?.id
+ );
+
+ response.status(200).json({ success: true, error: null });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ success: false, error: error.message });
+ }
+ }
+ );
+
+ /**
+ * Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it.
+ * or whatever the item type requires. This is not used if the item is a simple text responses like
+ * slash commands or system prompts.
+ */
+ app.post(
+ "/community-hub/import",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin]),
+ communityHubItem,
+ communityHubDownloadsEnabled,
+ ],
+ async (_, response) => {
+ try {
+ const { error: importError } = await CommunityHub.importBundleItem({
+ url: response.locals.bundleUrl,
+ item: response.locals.bundleItem,
+ });
+ if (importError) throw new Error(importError);
+
+ await Telemetry.sendTelemetry("community_hub_import", {
+ itemType: response.locals.bundleItem.itemType,
+ visibility: response.locals.bundleItem.visibility,
+ });
+ await EventLogs.logEvent(
+ "community_hub_import",
+ {
+ itemId: response.locals.bundleItem.id,
+ itemType: response.locals.bundleItem.itemType,
+ },
+ response.locals?.user?.id
+ );
+
+ response.status(200).json({ success: true, error: null });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ app.get(
+ "/community-hub/items",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_, response) => {
+ try {
+ const { connectionKey } = await SystemSettings.hubSettings();
+ const items = await CommunityHub.fetchUserItems(connectionKey);
+ response.status(200).json({ success: true, ...items });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ success: false, error: error.message });
+ }
+ }
+ );
+
+ app.post(
+ "/community-hub/:communityHubItemType/create",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { communityHubItemType } = request.params;
+ const { connectionKey } = await SystemSettings.hubSettings();
+ if (!connectionKey)
+ throw new Error("Community Hub connection key not found");
+
+ const data = reqBody(request);
+ const { success, error, itemId } = await CommunityHub.createStaticItem(
+ communityHubItemType,
+ data,
+ connectionKey
+ );
+ if (!success) throw new Error(error);
+
+ await EventLogs.logEvent(
+ "community_hub_publish",
+ { itemType: communityHubItemType },
+ response.locals?.user?.id
+ );
+ response
+ .status(200)
+ .json({ success: true, error: null, item: { id: itemId } });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({ success: false, error: error.message });
+ }
+ }
+ );
+}
+
+module.exports = { communityHubEndpoints };
diff --git a/server/endpoints/document.js b/server/endpoints/document.js
new file mode 100644
index 0000000000000000000000000000000000000000..e4c311aee514185bdd2d087eb3c952f7af4fe6ff
--- /dev/null
+++ b/server/endpoints/document.js
@@ -0,0 +1,111 @@
+const { Document } = require("../models/documents");
+const { normalizePath, documentsPath, isWithin } = require("../utils/files");
+const { reqBody } = require("../utils/http");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const fs = require("fs");
+const path = require("path");
+
+function documentEndpoints(app) {
+ if (!app) return;
+ app.post(
+ "/document/create-folder",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { name } = reqBody(request);
+ const storagePath = path.join(documentsPath, normalizePath(name));
+ if (!isWithin(path.resolve(documentsPath), path.resolve(storagePath)))
+ throw new Error("Invalid folder name.");
+
+ if (fs.existsSync(storagePath)) {
+ response.status(500).json({
+ success: false,
+ message: "Folder by that name already exists",
+ });
+ return;
+ }
+
+ fs.mkdirSync(storagePath, { recursive: true });
+ response.status(200).json({ success: true, message: null });
+ } catch (e) {
+ console.error(e);
+ response.status(500).json({
+ success: false,
+ message: `Failed to create folder: ${e.message} `,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/document/move-files",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { files } = reqBody(request);
+ const docpaths = files.map(({ from }) => from);
+ const documents = await Document.where({ docpath: { in: docpaths } });
+
+ const embeddedFiles = documents.map((doc) => doc.docpath);
+ const moveableFiles = files.filter(
+ ({ from }) => !embeddedFiles.includes(from)
+ );
+
+ const movePromises = moveableFiles.map(({ from, to }) => {
+ const sourcePath = path.join(documentsPath, normalizePath(from));
+ const destinationPath = path.join(documentsPath, normalizePath(to));
+
+ return new Promise((resolve, reject) => {
+ if (
+ !isWithin(documentsPath, sourcePath) ||
+ !isWithin(documentsPath, destinationPath)
+ )
+ return reject("Invalid file location");
+
+ fs.rename(sourcePath, destinationPath, (err) => {
+ if (err) {
+ console.error(`Error moving file ${from} to ${to}:`, err);
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+ });
+
+ Promise.all(movePromises)
+ .then(() => {
+ const unmovableCount = files.length - moveableFiles.length;
+ if (unmovableCount > 0) {
+ response.status(200).json({
+ success: true,
+ message: `${unmovableCount}/${files.length} files not moved. Unembed them from all workspaces.`,
+ });
+ } else {
+ response.status(200).json({
+ success: true,
+ message: null,
+ });
+ }
+ })
+ .catch((err) => {
+ console.error("Error moving files:", err);
+ response
+ .status(500)
+ .json({ success: false, message: "Failed to move some files." });
+ });
+ } catch (e) {
+ console.error(e);
+ response
+ .status(500)
+ .json({ success: false, message: "Failed to move files." });
+ }
+ }
+ );
+}
+
+module.exports = { documentEndpoints };
diff --git a/server/endpoints/embed/index.js b/server/endpoints/embed/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..4eb1d4a9e73a1cb4074cf79db215482bcc94b8f9
--- /dev/null
+++ b/server/endpoints/embed/index.js
@@ -0,0 +1,110 @@
+const { v4: uuidv4 } = require("uuid");
+const { reqBody, multiUserMode } = require("../../utils/http");
+const { Telemetry } = require("../../models/telemetry");
+const { streamChatWithForEmbed } = require("../../utils/chats/embed");
+const { EmbedChats } = require("../../models/embedChats");
+const {
+ validEmbedConfig,
+ canRespond,
+ setConnectionMeta,
+} = require("../../utils/middleware/embedMiddleware");
+const {
+ convertToChatHistory,
+ writeResponseChunk,
+} = require("../../utils/helpers/chat/responses");
+
+function embeddedEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/embed/:embedId/stream-chat",
+ [validEmbedConfig, setConnectionMeta, canRespond],
+ async (request, response) => {
+ try {
+ const embed = response.locals.embedConfig;
+ const {
+ sessionId,
+ message,
+ // optional keys for override of defaults if enabled.
+ prompt = null,
+ model = null,
+ temperature = null,
+ username = null,
+ } = reqBody(request);
+
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Content-Type", "text/event-stream");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Connection", "keep-alive");
+ response.flushHeaders();
+
+ await streamChatWithForEmbed(response, embed, message, sessionId, {
+ promptOverride: prompt,
+ modelOverride: model,
+ temperatureOverride: temperature,
+ username,
+ });
+ await Telemetry.sendTelemetry("embed_sent_chat", {
+ multiUserMode: multiUserMode(response),
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ });
+ response.end();
+ } catch (e) {
+ console.error(e);
+ writeResponseChunk(response, {
+ id: uuidv4(),
+ type: "abort",
+ sources: [],
+ textResponse: null,
+ close: true,
+ error: e.message,
+ });
+ response.end();
+ }
+ }
+ );
+
+ app.get(
+ "/embed/:embedId/:sessionId",
+ [validEmbedConfig],
+ async (request, response) => {
+ try {
+ const { sessionId } = request.params;
+ const embed = response.locals.embedConfig;
+ const history = await EmbedChats.forEmbedByUser(
+ embed.id,
+ sessionId,
+ null,
+ null,
+ true
+ );
+
+ response.status(200).json({ history: convertToChatHistory(history) });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/embed/:embedId/:sessionId",
+ [validEmbedConfig],
+ async (request, response) => {
+ try {
+ const { sessionId } = request.params;
+ const embed = response.locals.embedConfig;
+
+ await EmbedChats.markHistoryInvalid(embed.id, sessionId);
+ response.status(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { embeddedEndpoints };
diff --git a/server/endpoints/embedManagement.js b/server/endpoints/embedManagement.js
new file mode 100644
index 0000000000000000000000000000000000000000..8bee4dd75b4fb8ecd29faf673898e8eee272a844
--- /dev/null
+++ b/server/endpoints/embedManagement.js
@@ -0,0 +1,131 @@
+const { EmbedChats } = require("../models/embedChats");
+const { EmbedConfig } = require("../models/embedConfig");
+const { EventLogs } = require("../models/eventLogs");
+const { reqBody, userFromSession } = require("../utils/http");
+const { validEmbedConfigId } = require("../utils/middleware/embedMiddleware");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const {
+ chatHistoryViewable,
+} = require("../utils/middleware/chatHistoryViewable");
+
+function embedManagementEndpoints(app) {
+ if (!app) return;
+
+ app.get(
+ "/embeds",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_, response) => {
+ try {
+ const embeds = await EmbedConfig.whereWithWorkspace({}, null, {
+ createdAt: "desc",
+ });
+ response.status(200).json({ embeds });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/embeds/new",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const data = reqBody(request);
+ const { embed, message: error } = await EmbedConfig.new(data, user?.id);
+ await EventLogs.logEvent(
+ "embed_created",
+ { embedId: embed.id },
+ user?.id
+ );
+ response.status(200).json({ embed, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/embed/update/:embedId",
+ [validatedRequest, flexUserRoleValid([ROLES.admin]), validEmbedConfigId],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { embedId } = request.params;
+ const updates = reqBody(request);
+ const { success, error } = await EmbedConfig.update(embedId, updates);
+ await EventLogs.logEvent("embed_updated", { embedId }, user?.id);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/embed/:embedId",
+ [validatedRequest, flexUserRoleValid([ROLES.admin]), validEmbedConfigId],
+ async (request, response) => {
+ try {
+ const { embedId } = request.params;
+ await EmbedConfig.delete({ id: Number(embedId) });
+ await EventLogs.logEvent(
+ "embed_deleted",
+ { embedId },
+ response?.locals?.user?.id
+ );
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/embed/chats",
+ [chatHistoryViewable, validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { offset = 0, limit = 20 } = reqBody(request);
+ const embedChats = await EmbedChats.whereWithEmbedAndWorkspace(
+ {},
+ limit,
+ { id: "desc" },
+ offset * limit
+ );
+ const totalChats = await EmbedChats.count();
+ const hasPages = totalChats > (offset + 1) * limit;
+ response.status(200).json({ chats: embedChats, hasPages, totalChats });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/embed/chats/:chatId",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { chatId } = request.params;
+ await EmbedChats.delete({ id: Number(chatId) });
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { embedManagementEndpoints };
diff --git a/server/endpoints/experimental/imported-agent-plugins.js b/server/endpoints/experimental/imported-agent-plugins.js
new file mode 100644
index 0000000000000000000000000000000000000000..cabe23d89e785aed02de04a2b1c96cf512dadb31
--- /dev/null
+++ b/server/endpoints/experimental/imported-agent-plugins.js
@@ -0,0 +1,65 @@
+const ImportedPlugin = require("../../utils/agents/imported");
+const { reqBody } = require("../../utils/http");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../../utils/middleware/validatedRequest");
+
+function importedAgentPluginEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/experimental/agent-plugins/:hubId/toggle",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ (request, response) => {
+ try {
+ const { hubId } = request.params;
+ const { active } = reqBody(request);
+ const updatedConfig = ImportedPlugin.updateImportedPlugin(hubId, {
+ active: Boolean(active),
+ });
+ response.status(200).json(updatedConfig);
+ } catch (e) {
+ console.error(e);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/experimental/agent-plugins/:hubId/config",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ (request, response) => {
+ try {
+ const { hubId } = request.params;
+ const { updates } = reqBody(request);
+ const updatedConfig = ImportedPlugin.updateImportedPlugin(
+ hubId,
+ updates
+ );
+ response.status(200).json(updatedConfig);
+ } catch (e) {
+ console.error(e);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/experimental/agent-plugins/:hubId",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { hubId } = request.params;
+ const result = ImportedPlugin.deletePlugin(hubId);
+ response.status(200).json(result);
+ } catch (e) {
+ console.error(e);
+ response.status(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { importedAgentPluginEndpoints };
diff --git a/server/endpoints/experimental/index.js b/server/endpoints/experimental/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f7dc8678a800a51b0fc98b4ccf92ab67ac42298e
--- /dev/null
+++ b/server/endpoints/experimental/index.js
@@ -0,0 +1,12 @@
+const { liveSyncEndpoints } = require("./liveSync");
+const { importedAgentPluginEndpoints } = require("./imported-agent-plugins");
+
+// All endpoints here are not stable and can move around - have breaking changes
+// or are opt-in features that are not fully released.
+// When a feature is promoted it should be removed from here and added to the appropriate scope.
+function experimentalEndpoints(router) {
+ liveSyncEndpoints(router);
+ importedAgentPluginEndpoints(router);
+}
+
+module.exports = { experimentalEndpoints };
diff --git a/server/endpoints/experimental/liveSync.js b/server/endpoints/experimental/liveSync.js
new file mode 100644
index 0000000000000000000000000000000000000000..2a22d9a963b363ad898f5308ec1aa986f278c603
--- /dev/null
+++ b/server/endpoints/experimental/liveSync.js
@@ -0,0 +1,114 @@
+const { DocumentSyncQueue } = require("../../models/documentSyncQueue");
+const { Document } = require("../../models/documents");
+const { EventLogs } = require("../../models/eventLogs");
+const { SystemSettings } = require("../../models/systemSettings");
+const { Telemetry } = require("../../models/telemetry");
+const { reqBody } = require("../../utils/http");
+const {
+ featureFlagEnabled,
+} = require("../../utils/middleware/featureFlagEnabled");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../../utils/middleware/multiUserProtected");
+const { validWorkspaceSlug } = require("../../utils/middleware/validWorkspace");
+const { validatedRequest } = require("../../utils/middleware/validatedRequest");
+
+function liveSyncEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/experimental/toggle-live-sync",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { updatedStatus = false } = reqBody(request);
+ const newStatus =
+ SystemSettings.validations.experimental_live_file_sync(updatedStatus);
+ const currentStatus =
+ (await SystemSettings.get({ label: "experimental_live_file_sync" }))
+ ?.value || "disabled";
+ if (currentStatus === newStatus)
+ return response
+ .status(200)
+ .json({ liveSyncEnabled: newStatus === "enabled" });
+
+ // Already validated earlier - so can hot update.
+ await SystemSettings._updateSettings({
+ experimental_live_file_sync: newStatus,
+ });
+ if (newStatus === "enabled") {
+ await Telemetry.sendTelemetry("experimental_feature_enabled", {
+ feature: "live_file_sync",
+ });
+ await EventLogs.logEvent("experimental_feature_enabled", {
+ feature: "live_file_sync",
+ });
+ DocumentSyncQueue.bootWorkers();
+ } else {
+ DocumentSyncQueue.killWorkers();
+ }
+
+ response.status(200).json({ liveSyncEnabled: newStatus === "enabled" });
+ } catch (e) {
+ console.error(e);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/experimental/live-sync/queues",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin]),
+ featureFlagEnabled(DocumentSyncQueue.featureKey),
+ ],
+ async (_, response) => {
+ const queues = await DocumentSyncQueue.where(
+ {},
+ null,
+ { createdAt: "asc" },
+ {
+ workspaceDoc: {
+ include: {
+ workspace: true,
+ },
+ },
+ }
+ );
+ response.status(200).json({ queues });
+ }
+ );
+
+ // Should be in workspace routes, but is here for now.
+ app.post(
+ "/workspace/:slug/update-watch-status",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ validWorkspaceSlug,
+ featureFlagEnabled(DocumentSyncQueue.featureKey),
+ ],
+ async (request, response) => {
+ try {
+ const { docPath, watchStatus = false } = reqBody(request);
+ const workspace = response.locals.workspace;
+
+ const document = await Document.get({
+ workspaceId: workspace.id,
+ docpath: docPath,
+ });
+ if (!document) return response.sendStatus(404).end();
+
+ await DocumentSyncQueue.toggleWatchStatus(document, watchStatus);
+ return response.status(200).end();
+ } catch (error) {
+ console.error("Error processing the watch status update:", error);
+ return response.status(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { liveSyncEndpoints };
diff --git a/server/endpoints/extensions/index.js b/server/endpoints/extensions/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..0a4acea6cc0b02bf67c428ede83aaf473cdd11c0
--- /dev/null
+++ b/server/endpoints/extensions/index.js
@@ -0,0 +1,175 @@
+const { Telemetry } = require("../../models/telemetry");
+const { CollectorApi } = require("../../utils/collectorApi");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../../utils/middleware/validatedRequest");
+const {
+ isSupportedRepoProvider,
+} = require("../../utils/middleware/isSupportedRepoProviders");
+
+function extensionEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/ext/:repo_platform/branches",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ isSupportedRepoProvider,
+ ],
+ async (request, response) => {
+ try {
+ const { repo_platform } = request.params;
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: `/ext/${repo_platform}-repo/branches`,
+ method: "POST",
+ body: request.body,
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/ext/:repo_platform/repo",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ isSupportedRepoProvider,
+ ],
+ async (request, response) => {
+ try {
+ const { repo_platform } = request.params;
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: `/ext/${repo_platform}-repo`,
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: `${repo_platform}_repo`,
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/ext/youtube/transcript",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: "/ext/youtube-transcript",
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: "youtube_transcript",
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/ext/confluence",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: "/ext/confluence",
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: "confluence",
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+ app.post(
+ "/ext/website-depth",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: "/ext/website-depth",
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: "website_depth",
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+ app.post(
+ "/ext/drupalwiki",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: "/ext/drupalwiki",
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: "drupalwiki",
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/ext/obsidian/vault",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const responseFromProcessor =
+ await new CollectorApi().forwardExtensionRequest({
+ endpoint: "/ext/obsidian/vault",
+ method: "POST",
+ body: request.body,
+ });
+ await Telemetry.sendTelemetry("extension_invoked", {
+ type: "obsidian_vault",
+ });
+ response.status(200).json(responseFromProcessor);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { extensionEndpoints };
diff --git a/server/endpoints/invite.js b/server/endpoints/invite.js
new file mode 100644
index 0000000000000000000000000000000000000000..9a05ed7cf575a9029c1b13eb38d37289d4e1f469
--- /dev/null
+++ b/server/endpoints/invite.js
@@ -0,0 +1,83 @@
+const { EventLogs } = require("../models/eventLogs");
+const { Invite } = require("../models/invite");
+const { User } = require("../models/user");
+const { reqBody } = require("../utils/http");
+const {
+ simpleSSOLoginDisabledMiddleware,
+} = require("../utils/middleware/simpleSSOEnabled");
+
+function inviteEndpoints(app) {
+ if (!app) return;
+
+ app.get("/invite/:code", async (request, response) => {
+ try {
+ const { code } = request.params;
+ const invite = await Invite.get({ code });
+ if (!invite) {
+ response.status(200).json({ invite: null, error: "Invite not found." });
+ return;
+ }
+
+ if (invite.status !== "pending") {
+ response
+ .status(200)
+ .json({ invite: null, error: "Invite is no longer valid." });
+ return;
+ }
+
+ response
+ .status(200)
+ .json({ invite: { code, status: invite.status }, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.post(
+ "/invite/:code",
+ [simpleSSOLoginDisabledMiddleware],
+ async (request, response) => {
+ try {
+ const { code } = request.params;
+ const { username, password } = reqBody(request);
+ const invite = await Invite.get({ code });
+ if (!invite || invite.status !== "pending") {
+ response
+ .status(200)
+ .json({ success: false, error: "Invite not found or is invalid." });
+ return;
+ }
+
+ const { user, error } = await User.create({
+ username,
+ password,
+ role: "default",
+ });
+ if (!user) {
+ console.error("Accepting invite:", error);
+ response
+ .status(200)
+ .json({ success: false, error: "Could not create user." });
+ return;
+ }
+
+ await Invite.markClaimed(invite.id, user);
+ await EventLogs.logEvent(
+ "invite_accepted",
+ {
+ username: user.username,
+ },
+ user.id
+ );
+
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { inviteEndpoints };
diff --git a/server/endpoints/mcpServers.js b/server/endpoints/mcpServers.js
new file mode 100644
index 0000000000000000000000000000000000000000..3cd5a8656963b06cd85d303ac29bdb7bf9f2c3ce
--- /dev/null
+++ b/server/endpoints/mcpServers.js
@@ -0,0 +1,100 @@
+const { reqBody } = require("../utils/http");
+const MCPCompatibilityLayer = require("../utils/MCP");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+
+function mcpServersEndpoints(app) {
+ if (!app) return;
+
+ app.get(
+ "/mcp-servers/force-reload",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_request, response) => {
+ try {
+ const mcp = new MCPCompatibilityLayer();
+ await mcp.reloadMCPServers();
+ return response.status(200).json({
+ success: true,
+ error: null,
+ servers: await mcp.servers(),
+ });
+ } catch (error) {
+ console.error("Error force reloading MCP servers:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ servers: [],
+ });
+ }
+ }
+ );
+
+ app.get(
+ "/mcp-servers/list",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_request, response) => {
+ try {
+ const servers = await new MCPCompatibilityLayer().servers();
+ return response.status(200).json({
+ success: true,
+ servers,
+ });
+ } catch (error) {
+ console.error("Error listing MCP servers:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/mcp-servers/toggle",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { name } = reqBody(request);
+ const result = await new MCPCompatibilityLayer().toggleServerStatus(
+ name
+ );
+ return response.status(200).json({
+ success: result.success,
+ error: result.error,
+ });
+ } catch (error) {
+ console.error("Error toggling MCP server:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/mcp-servers/delete",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { name } = reqBody(request);
+ const result = await new MCPCompatibilityLayer().deleteServer(name);
+ return response.status(200).json({
+ success: result.success,
+ error: result.error,
+ });
+ } catch (error) {
+ console.error("Error deleting MCP server:", error);
+ return response.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+ }
+ );
+}
+
+module.exports = { mcpServersEndpoints };
diff --git a/server/endpoints/mobile/index.js b/server/endpoints/mobile/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..ca9e11a93d6998040ceb8ec07ee0151da1531ddc
--- /dev/null
+++ b/server/endpoints/mobile/index.js
@@ -0,0 +1,160 @@
+const { validatedRequest } = require("../../utils/middleware/validatedRequest");
+const { MobileDevice } = require("../../models/mobileDevice");
+const { handleMobileCommand } = require("./utils");
+const { validDeviceToken, validRegistrationToken } = require("./middleware");
+const { reqBody } = require("../../utils/http");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../../utils/middleware/multiUserProtected");
+
+function mobileEndpoints(app) {
+ if (!app) return;
+
+ /**
+ * Gets all the devices from the database.
+ * @param {import("express").Request} request
+ * @param {import("express").Response} response
+ */
+ app.get(
+ "/mobile/devices",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_request, response) => {
+ try {
+ const devices = await MobileDevice.where({}, null, null, {
+ user: { select: { id: true, username: true } },
+ });
+ return response.status(200).json({ devices });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ /**
+ * Updates the device status via an updates object.
+ * @param {import("express").Request} request
+ * @param {import("express").Response} response
+ */
+ app.post(
+ "/mobile/update/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const body = reqBody(request);
+ const updates = await MobileDevice.update(
+ Number(request.params.id),
+ body
+ );
+ if (updates.error)
+ return response.status(400).json({ error: updates.error });
+ return response.status(200).json({ updates });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ /**
+ * Deletes a device from the database.
+ * @param {import("express").Request} request
+ * @param {import("express").Response} response
+ */
+ app.delete(
+ "/mobile/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const device = await MobileDevice.get({
+ id: Number(request.params.id),
+ });
+ if (!device)
+ return response.status(404).json({ error: "Device not found" });
+ await MobileDevice.delete(device.id);
+ return response.status(200).json({ message: "Device deleted" });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/mobile/connect-info",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_request, response) => {
+ try {
+ return response.status(200).json({
+ connectionUrl: MobileDevice.connectionURL(response.locals?.user),
+ });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ /**
+ * Checks if the device auth token is valid
+ * against approved devices.
+ */
+ app.get("/mobile/auth", [validDeviceToken], async (_, response) => {
+ try {
+ return response
+ .status(200)
+ .json({ success: true, message: "Device authenticated" });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ /**
+ * Registers a new device (is open so that the mobile app can register itself)
+ * Will create a new device in the database but requires approval by the user
+ * before it can be used.
+ * @param {import("express").Request} request
+ * @param {import("express").Response} response
+ */
+ app.post(
+ "/mobile/register",
+ [validRegistrationToken],
+ async (request, response) => {
+ try {
+ const body = reqBody(request);
+ const result = await MobileDevice.create({
+ deviceOs: body.deviceOs,
+ deviceName: body.deviceName,
+ userId: response.locals?.user?.id,
+ });
+
+ if (result.error)
+ return response.status(400).json({ error: result.error });
+ return response.status(200).json({
+ token: result.device.token,
+ platform: MobileDevice.platform,
+ });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/mobile/send/:command",
+ [validDeviceToken],
+ async (request, response) => {
+ try {
+ return handleMobileCommand(request, response);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { mobileEndpoints };
diff --git a/server/endpoints/mobile/middleware/index.js b/server/endpoints/mobile/middleware/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..148f5e189271fd12b096cc784e1b02be9dfb4df4
--- /dev/null
+++ b/server/endpoints/mobile/middleware/index.js
@@ -0,0 +1,97 @@
+const { MobileDevice } = require("../../../models/mobileDevice");
+const { SystemSettings } = require("../../../models/systemSettings");
+const { User } = require("../../../models/user");
+
+/**
+ * Validates the device id from the request headers by checking if the device
+ * exists in the database and is approved.
+ * @param {import("express").Request} request
+ * @param {import("express").Response} response
+ * @param {import("express").NextFunction} next
+ */
+async function validDeviceToken(request, response, next) {
+ try {
+ const token = request.header("x-anythingllm-mobile-device-token");
+ if (!token)
+ return response.status(400).json({ error: "Device token is required" });
+
+ const device = await MobileDevice.get(
+ { token: String(token) },
+ { user: true }
+ );
+ if (!device)
+ return response.status(400).json({ error: "Device not found" });
+ if (!device.approved)
+ return response.status(400).json({ error: "Device not approved" });
+
+ // If the device is associated with a user then we can associate it with the locals
+ // so we can reuse it later.
+ if (device.user) {
+ if (device.user.suspended)
+ return response.status(400).json({ error: "User is suspended." });
+ response.locals.user = device.user;
+ }
+
+ delete device.user;
+ response.locals.device = device;
+ next();
+ } catch (error) {
+ console.error("validDeviceToken", error);
+ response.status(500).json({ error: "Invalid middleware response" });
+ }
+}
+
+/**
+ * Validates a temporary registration token that is passed in the request
+ * and associates the user with the token (if valid). Temporary token is consumed
+ * and cannot be used again after this middleware is called.
+ * @param {*} request
+ * @param {*} response
+ * @param {*} next
+ */
+async function validRegistrationToken(request, response, next) {
+ try {
+ const authHeader = request.header("Authorization");
+ const tempToken = authHeader ? authHeader.split(" ")[1] : null;
+ if (!tempToken)
+ return response
+ .status(400)
+ .json({ error: "Registration token is required" });
+
+ const tempTokenData = MobileDevice.tempToken(tempToken);
+ if (!tempTokenData)
+ return response
+ .status(400)
+ .json({ error: "Invalid or expired registration token" });
+
+ // If in multi-user mode, we need to validate the user id
+ // associated exists, is not banned and then associate with locals so we can reuse it later.
+ // If not in multi-user mode then simply having a valid token is enough.
+ const multiUserMode = await SystemSettings.isMultiUserMode();
+ if (multiUserMode) {
+ if (!tempTokenData.userId)
+ return response
+ .status(400)
+ .json({ error: "User id not found in registration token" });
+ const user = await User.get({ id: Number(tempTokenData.userId) });
+ if (!user) return response.status(400).json({ error: "User not found" });
+ if (user.suspended)
+ return response
+ .status(400)
+ .json({ error: "User is suspended - cannot register device" });
+ response.locals.user = user;
+ }
+
+ next();
+ } catch (error) {
+ console.error("validRegistrationToken:error", error);
+ response.status(500).json({
+ error: "Invalid middleware response from validRegistrationToken",
+ });
+ }
+}
+
+module.exports = {
+ validDeviceToken,
+ validRegistrationToken,
+};
diff --git a/server/endpoints/mobile/utils/index.js b/server/endpoints/mobile/utils/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..88be878597819fae8e581670f4f92c0bd7e51aa9
--- /dev/null
+++ b/server/endpoints/mobile/utils/index.js
@@ -0,0 +1,195 @@
+const { Workspace } = require("../../../models/workspace");
+const { WorkspaceChats } = require("../../../models/workspaceChats");
+const { WorkspaceThread } = require("../../../models/workspaceThread");
+const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler");
+const { reqBody } = require("../../../utils/http");
+const prisma = require("../../../utils/prisma");
+const { getModelTag } = require("../../utils");
+const { MobileDevice } = require("../../../models/mobileDevice");
+
+/**
+ *
+ * @param {import("express").Request} request
+ * @param {import("express").Response} response
+ * @returns
+ */
+async function handleMobileCommand(request, response) {
+ const { command } = request.params;
+ const user = response.locals.user ?? null;
+ const body = reqBody(request);
+
+ if (command === "workspaces") {
+ const workspaces = user
+ ? await Workspace.whereWithUser(user, {})
+ : await Workspace.where({});
+ for (const workspace of workspaces) {
+ const [threadCount, chatCount] = await Promise.all([
+ prisma.workspace_threads.count({
+ where: {
+ workspace_id: workspace.id,
+ ...(user ? { user_id: user.id } : {}),
+ },
+ }),
+ prisma.workspace_chats.count({
+ where: {
+ workspaceId: workspace.id,
+ include: true,
+ ...(user ? { user_id: user.id } : {}),
+ },
+ }),
+ ]);
+ workspace.threadCount = threadCount;
+ workspace.chatCount = chatCount;
+ workspace.platform = MobileDevice.platform;
+ }
+ return response.status(200).json({ workspaces });
+ }
+
+ if (command === "workspace-content") {
+ const workspace = user
+ ? await Workspace.getWithUser(user, { slug: String(body.workspaceSlug) })
+ : await Workspace.get({ slug: String(body.workspaceSlug) });
+
+ if (!workspace)
+ return response.status(400).json({ error: "Workspace not found" });
+ const threads = [
+ {
+ id: 0,
+ name: "Default Thread",
+ slug: "default-thread",
+ workspace_id: workspace.id,
+ createdAt: new Date(),
+ lastUpdatedAt: new Date(),
+ },
+ ...(await prisma.workspace_threads.findMany({
+ where: {
+ workspace_id: workspace.id,
+ ...(user ? { user_id: user.id } : {}),
+ },
+ })),
+ ];
+ const chats = (
+ await prisma.workspace_chats.findMany({
+ where: {
+ workspaceId: workspace.id,
+ include: true,
+ ...(user ? { user_id: user.id } : {}),
+ },
+ })
+ ).map((chat) => ({
+ ...chat,
+ // Create a dummy thread_id for the default thread so the chats can be mapped correctly.
+ ...(chat.thread_id === null ? { thread_id: 0 } : {}),
+ createdAt: chat.createdAt.toISOString(),
+ lastUpdatedAt: chat.lastUpdatedAt.toISOString(),
+ }));
+ return response.status(200).json({ threads, chats });
+ }
+
+ // Get the model for this workspace (workspace -> system)
+ if (command === "model-tag") {
+ const { workspaceSlug } = body;
+ const workspace = user
+ ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
+ : await Workspace.get({ slug: String(workspaceSlug) });
+
+ if (!workspace)
+ return response.status(400).json({ error: "Workspace not found" });
+ if (workspace.chatModel)
+ return response.status(200).json({ model: workspace.chatModel });
+ else return response.status(200).json({ model: getModelTag() });
+ }
+
+ if (command === "reset-chat") {
+ const { workspaceSlug, threadSlug } = body;
+ const workspace = user
+ ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
+ : await Workspace.get({ slug: String(workspaceSlug) });
+
+ if (!workspace)
+ return response.status(400).json({ error: "Workspace not found" });
+ const threadId = threadSlug
+ ? await prisma.workspace_threads.findFirst({
+ where: {
+ workspace_id: workspace.id,
+ slug: String(threadSlug),
+ ...(user ? { user_id: user.id } : {}),
+ },
+ })?.id
+ : null;
+
+ await WorkspaceChats.markThreadHistoryInvalidV2({
+ workspaceId: workspace.id,
+ ...(user ? { user_id: user.id } : {}),
+ thread_id: threadId, // if threadId is null, this will reset the default thread.
+ });
+ return response.status(200).json({ success: true });
+ }
+
+ if (command === "new-thread") {
+ const { workspaceSlug } = body;
+ const workspace = user
+ ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
+ : await Workspace.get({ slug: String(workspaceSlug) });
+
+ if (!workspace)
+ return response.status(400).json({ error: "Workspace not found" });
+ const { thread } = await WorkspaceThread.new(workspace, user?.id);
+ return response.status(200).json({ thread });
+ }
+
+ if (command === "stream-chat") {
+ const { workspaceSlug = null, threadSlug = null, message } = body;
+ if (!workspaceSlug)
+ return response.status(400).json({ error: "Workspace ID is required" });
+ else if (!message)
+ return response.status(400).json({ error: "Message is required" });
+
+ const workspace = user
+ ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
+ : await Workspace.get({ slug: String(workspaceSlug) });
+
+ if (!workspace)
+ return response.status(400).json({ error: "Workspace not found" });
+ const thread = threadSlug
+ ? await prisma.workspace_threads.findFirst({
+ where: {
+ workspace_id: workspace.id,
+ slug: String(threadSlug),
+ ...(user ? { user_id: user.id } : {}),
+ },
+ })
+ : null;
+
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Content-Type", "text/event-stream");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Connection", "keep-alive");
+ response.flushHeaders();
+ await ApiChatHandler.streamChat({
+ response,
+ workspace,
+ thread,
+ message,
+ mode: "chat",
+ user: user,
+ sessionId: null,
+ attachments: [],
+ reset: false,
+ });
+ return response.end();
+ }
+
+ if (command === "unregister-device") {
+ if (!response.locals.device)
+ return response.status(200).json({ success: true });
+ await MobileDevice.delete(response.locals.device.id);
+ return response.status(200).json({ success: true });
+ }
+
+ return response.status(400).json({ error: "Invalid command" });
+}
+
+module.exports = {
+ handleMobileCommand,
+};
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
new file mode 100644
index 0000000000000000000000000000000000000000..fcefd338cc86444e00a9fc73b9754d65fd58c7bf
--- /dev/null
+++ b/server/endpoints/system.js
@@ -0,0 +1,1421 @@
+process.env.NODE_ENV === "development"
+ ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` })
+ : require("dotenv").config();
+const { viewLocalFiles, normalizePath, isWithin } = require("../utils/files");
+const { purgeDocument, purgeFolder } = require("../utils/files/purgeDocument");
+const { getVectorDbClass } = require("../utils/helpers");
+const { updateENV, dumpENV } = require("../utils/helpers/updateENV");
+const {
+ reqBody,
+ makeJWT,
+ userFromSession,
+ multiUserMode,
+ queryParams,
+} = require("../utils/http");
+const { handleAssetUpload, handlePfpUpload } = require("../utils/files/multer");
+const { v4 } = require("uuid");
+const { SystemSettings } = require("../models/systemSettings");
+const { User } = require("../models/user");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const fs = require("fs");
+const path = require("path");
+const {
+ getDefaultFilename,
+ determineLogoFilepath,
+ fetchLogo,
+ validFilename,
+ renameLogoFile,
+ removeCustomLogo,
+ LOGO_FILENAME,
+ isDefaultFilename,
+} = require("../utils/files/logo");
+const { Telemetry } = require("../models/telemetry");
+const { WelcomeMessages } = require("../models/welcomeMessages");
+const { ApiKey } = require("../models/apiKeys");
+const { getCustomModels } = require("../utils/helpers/customModels");
+const { WorkspaceChats } = require("../models/workspaceChats");
+const {
+ flexUserRoleValid,
+ ROLES,
+ isMultiUserSetup,
+} = require("../utils/middleware/multiUserProtected");
+const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp");
+const { exportChatsAsType } = require("../utils/helpers/chat/convertTo");
+const { EventLogs } = require("../models/eventLogs");
+const { CollectorApi } = require("../utils/collectorApi");
+const {
+ recoverAccount,
+ resetPassword,
+ generateRecoveryCodes,
+} = require("../utils/PasswordRecovery");
+const { SlashCommandPresets } = require("../models/slashCommandsPresets");
+const { EncryptionManager } = require("../utils/EncryptionManager");
+const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
+const {
+ chatHistoryViewable,
+} = require("../utils/middleware/chatHistoryViewable");
+const {
+ simpleSSOEnabled,
+ simpleSSOLoginDisabled,
+} = require("../utils/middleware/simpleSSOEnabled");
+const { TemporaryAuthToken } = require("../models/temporaryAuthToken");
+const { SystemPromptVariables } = require("../models/systemPromptVariables");
+const { VALID_COMMANDS } = require("../utils/chats");
+
+function systemEndpoints(app) {
+ if (!app) return;
+
+ app.get("/ping", (_, response) => {
+ response.status(200).json({ online: true });
+ });
+
+ app.get("/migrate", async (_, response) => {
+ response.sendStatus(200);
+ });
+
+ app.get("/env-dump", async (_, response) => {
+ if (process.env.NODE_ENV !== "production")
+ return response.sendStatus(200).end();
+ dumpENV();
+ response.sendStatus(200).end();
+ });
+
+ app.get("/setup-complete", async (_, response) => {
+ try {
+ const results = await SystemSettings.currentSettings();
+ response.status(200).json({ results });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get(
+ "/system/check-token",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ if (multiUserMode(response)) {
+ const user = await userFromSession(request, response);
+ if (!user || user.suspended) {
+ response.sendStatus(403).end();
+ return;
+ }
+
+ response.sendStatus(200).end();
+ return;
+ }
+
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post("/request-token", async (request, response) => {
+ try {
+ const bcrypt = require("bcrypt");
+
+ if (await SystemSettings.isMultiUserMode()) {
+ if (simpleSSOLoginDisabled()) {
+ response.status(403).json({
+ user: null,
+ valid: false,
+ token: null,
+ message:
+ "[005] Login via credentials has been disabled by the administrator.",
+ });
+ return;
+ }
+
+ const { username, password } = reqBody(request);
+ const existingUser = await User._get({ username: String(username) });
+
+ if (!existingUser) {
+ await EventLogs.logEvent(
+ "failed_login_invalid_username",
+ {
+ ip: request.ip || "Unknown IP",
+ username: username || "Unknown user",
+ },
+ existingUser?.id
+ );
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[001] Invalid login credentials.",
+ });
+ return;
+ }
+
+ if (!bcrypt.compareSync(String(password), existingUser.password)) {
+ await EventLogs.logEvent(
+ "failed_login_invalid_password",
+ {
+ ip: request.ip || "Unknown IP",
+ username: username || "Unknown user",
+ },
+ existingUser?.id
+ );
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[002] Invalid login credentials.",
+ });
+ return;
+ }
+
+ if (existingUser.suspended) {
+ await EventLogs.logEvent(
+ "failed_login_account_suspended",
+ {
+ ip: request.ip || "Unknown IP",
+ username: username || "Unknown user",
+ },
+ existingUser?.id
+ );
+ response.status(200).json({
+ user: null,
+ valid: false,
+ token: null,
+ message: "[004] Account suspended by admin.",
+ });
+ return;
+ }
+
+ await Telemetry.sendTelemetry(
+ "login_event",
+ { multiUserMode: false },
+ existingUser?.id
+ );
+
+ await EventLogs.logEvent(
+ "login_event",
+ {
+ ip: request.ip || "Unknown IP",
+ username: existingUser.username || "Unknown user",
+ },
+ existingUser?.id
+ );
+
+ // Generate a session token for the user then check if they have seen the recovery codes
+ // and if not, generate recovery codes and return them to the frontend.
+ const sessionToken = makeJWT(
+ { id: existingUser.id, username: existingUser.username },
+ process.env.JWT_EXPIRY
+ );
+ if (!existingUser.seen_recovery_codes) {
+ const plainTextCodes = await generateRecoveryCodes(existingUser.id);
+ response.status(200).json({
+ valid: true,
+ user: User.filterFields(existingUser),
+ token: sessionToken,
+ message: null,
+ recoveryCodes: plainTextCodes,
+ });
+ return;
+ }
+
+ response.status(200).json({
+ valid: true,
+ user: User.filterFields(existingUser),
+ token: sessionToken,
+ message: null,
+ });
+ return;
+ } else {
+ const { password } = reqBody(request);
+ if (
+ !bcrypt.compareSync(
+ password,
+ bcrypt.hashSync(process.env.AUTH_TOKEN, 10)
+ )
+ ) {
+ await EventLogs.logEvent("failed_login_invalid_password", {
+ ip: request.ip || "Unknown IP",
+ multiUserMode: false,
+ });
+ response.status(401).json({
+ valid: false,
+ token: null,
+ message: "[003] Invalid password provided",
+ });
+ return;
+ }
+
+ await Telemetry.sendTelemetry("login_event", { multiUserMode: false });
+ await EventLogs.logEvent("login_event", {
+ ip: request.ip || "Unknown IP",
+ multiUserMode: false,
+ });
+ response.status(200).json({
+ valid: true,
+ token: makeJWT(
+ { p: new EncryptionManager().encrypt(password) },
+ process.env.JWT_EXPIRY
+ ),
+ message: null,
+ });
+ }
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get(
+ "/request-token/sso/simple",
+ [simpleSSOEnabled],
+ async (request, response) => {
+ const { token: tempAuthToken } = request.query;
+ const { sessionToken, token, error } =
+ await TemporaryAuthToken.validate(tempAuthToken);
+
+ if (error) {
+ await EventLogs.logEvent("failed_login_invalid_temporary_auth_token", {
+ ip: request.ip || "Unknown IP",
+ multiUserMode: true,
+ });
+ return response.status(401).json({
+ valid: false,
+ token: null,
+ message: `[001] An error occurred while validating the token: ${error}`,
+ });
+ }
+
+ await Telemetry.sendTelemetry(
+ "login_event",
+ { multiUserMode: true },
+ token.user.id
+ );
+ await EventLogs.logEvent(
+ "login_event",
+ {
+ ip: request.ip || "Unknown IP",
+ username: token.user.username || "Unknown user",
+ },
+ token.user.id
+ );
+
+ response.status(200).json({
+ valid: true,
+ user: User.filterFields(token.user),
+ token: sessionToken,
+ message: null,
+ });
+ }
+ );
+
+ app.post(
+ "/system/recover-account",
+ [isMultiUserSetup],
+ async (request, response) => {
+ try {
+ const { username, recoveryCodes } = reqBody(request);
+ const { success, resetToken, error } = await recoverAccount(
+ username,
+ recoveryCodes
+ );
+
+ if (success) {
+ response.status(200).json({ success, resetToken });
+ } else {
+ response.status(400).json({ success, message: error });
+ }
+ } catch (error) {
+ console.error("Error recovering account:", error);
+ response
+ .status(500)
+ .json({ success: false, message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/system/reset-password",
+ [isMultiUserSetup],
+ async (request, response) => {
+ try {
+ const { token, newPassword, confirmPassword } = reqBody(request);
+ const { success, message, error } = await resetPassword(
+ token,
+ newPassword,
+ confirmPassword
+ );
+
+ if (success) {
+ response.status(200).json({ success, message });
+ } else {
+ response.status(400).json({ success, error });
+ }
+ } catch (error) {
+ console.error("Error resetting password:", error);
+ response.status(500).json({ success: false, message: error.message });
+ }
+ }
+ );
+
+ app.get(
+ "/system/system-vectors",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const query = queryParams(request);
+ const VectorDb = getVectorDbClass();
+ const vectorCount = !!query.slug
+ ? await VectorDb.namespaceCount(query.slug)
+ : await VectorDb.totalVectors();
+ response.status(200).json({ vectorCount });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/system/remove-document",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { name } = reqBody(request);
+ await purgeDocument(name);
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/system/remove-documents",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { names } = reqBody(request);
+ for await (const name of names) await purgeDocument(name);
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/system/remove-folder",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { name } = reqBody(request);
+ await purgeFolder(name);
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/system/local-files",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (_, response) => {
+ try {
+ const localFiles = await viewLocalFiles();
+ response.status(200).json({ localFiles });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/system/document-processing-status",
+ [validatedRequest],
+ async (_, response) => {
+ try {
+ const online = await new CollectorApi().online();
+ response.sendStatus(online ? 200 : 503);
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/system/accepted-document-types",
+ [validatedRequest],
+ async (_, response) => {
+ try {
+ const types = await new CollectorApi().acceptedFileTypes();
+ if (!types) {
+ response.sendStatus(404).end();
+ return;
+ }
+
+ response.status(200).json({ types });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/system/update-env",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const body = reqBody(request);
+ const { newValues, error } = await updateENV(
+ body,
+ false,
+ response?.locals?.user?.id
+ );
+ response.status(200).json({ newValues, error });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/system/update-password",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ // Cannot update password in multi - user mode.
+ if (multiUserMode(response)) {
+ response.sendStatus(401).end();
+ return;
+ }
+
+ let error = null;
+ const { usePassword, newPassword } = reqBody(request);
+ if (!usePassword) {
+ // Password is being disabled so directly unset everything to bypass validation.
+ process.env.AUTH_TOKEN = "";
+ process.env.JWT_SECRET = "";
+ } else {
+ error = await updateENV(
+ {
+ AuthToken: newPassword,
+ JWTSecret: v4(),
+ },
+ true
+ )?.error;
+ }
+ response.status(200).json({ success: !error, error });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/system/enable-multi-user",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ if (response.locals.multiUserMode) {
+ response.status(200).json({
+ success: false,
+ error: "Multi-user mode is already enabled.",
+ });
+ return;
+ }
+
+ const { username, password } = reqBody(request);
+ const { user, error } = await User.create({
+ username,
+ password,
+ role: ROLES.admin,
+ });
+
+ if (error || !user) {
+ response.status(400).json({
+ success: false,
+ error: error || "Failed to enable multi-user mode.",
+ });
+ return;
+ }
+
+ await SystemSettings._updateSettings({
+ multi_user_mode: true,
+ });
+ await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);
+
+ await updateENV(
+ {
+ JWTSecret: process.env.JWT_SECRET || v4(),
+ },
+ true
+ );
+ await Telemetry.sendTelemetry("enabled_multi_user_mode", {
+ multiUserMode: true,
+ });
+ await EventLogs.logEvent("multi_user_mode_enabled", {}, user?.id);
+ response.status(200).json({ success: !!user, error });
+ } catch (e) {
+ await User.delete({});
+ await SystemSettings._updateSettings({
+ multi_user_mode: false,
+ });
+
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get("/system/multi-user-mode", async (_, response) => {
+ try {
+ const multiUserMode = await SystemSettings.isMultiUserMode();
+ response.status(200).json({ multiUserMode });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get("/system/logo", async function (request, response) {
+ try {
+ const darkMode =
+ !request?.query?.theme || request?.query?.theme === "default";
+ const defaultFilename = getDefaultFilename(darkMode);
+ const logoPath = await determineLogoFilepath(defaultFilename);
+ const { found, buffer, size, mime } = fetchLogo(logoPath);
+
+ if (!found) {
+ response.sendStatus(204).end();
+ return;
+ }
+
+ const currentLogoFilename = await SystemSettings.currentLogoFilename();
+ response.writeHead(200, {
+ "Access-Control-Expose-Headers":
+ "Content-Disposition,X-Is-Custom-Logo,Content-Type,Content-Length",
+ "Content-Type": mime || "image/png",
+ "Content-Disposition": `attachment; filename=${path.basename(
+ logoPath
+ )}`,
+ "Content-Length": size,
+ "X-Is-Custom-Logo":
+ currentLogoFilename !== null &&
+ currentLogoFilename !== defaultFilename &&
+ !isDefaultFilename(currentLogoFilename),
+ });
+ response.end(Buffer.from(buffer, "base64"));
+ return;
+ } catch (error) {
+ console.error("Error processing the logo request:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ });
+
+ app.get("/system/footer-data", [validatedRequest], async (_, response) => {
+ try {
+ const footerData =
+ (await SystemSettings.get({ label: "footer_data" }))?.value ??
+ JSON.stringify([]);
+ response.status(200).json({ footerData: footerData });
+ } catch (error) {
+ console.error("Error fetching footer data:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ });
+
+ app.get("/system/support-email", [validatedRequest], async (_, response) => {
+ try {
+ const supportEmail =
+ (
+ await SystemSettings.get({
+ label: "support_email",
+ })
+ )?.value ?? null;
+ response.status(200).json({ supportEmail: supportEmail });
+ } catch (error) {
+ console.error("Error fetching support email:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ });
+
+ // No middleware protection in order to get this on the login page
+ app.get("/system/custom-app-name", async (_, response) => {
+ try {
+ const customAppName =
+ (
+ await SystemSettings.get({
+ label: "custom_app_name",
+ })
+ )?.value ?? null;
+ response.status(200).json({ customAppName: customAppName });
+ } catch (error) {
+ console.error("Error fetching custom app name:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ });
+
+ app.get(
+ "/system/pfp/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async function (request, response) {
+ try {
+ const { id } = request.params;
+ if (response.locals?.user?.id !== Number(id))
+ return response.sendStatus(204).end();
+
+ const pfpPath = await determinePfpFilepath(id);
+ if (!pfpPath) return response.sendStatus(204).end();
+
+ const { found, buffer, size, mime } = fetchPfp(pfpPath);
+ if (!found) return response.sendStatus(204).end();
+
+ response.writeHead(200, {
+ "Content-Type": mime || "image/png",
+ "Content-Disposition": `attachment; filename=${path.basename(pfpPath)}`,
+ "Content-Length": size,
+ });
+ response.end(Buffer.from(buffer, "base64"));
+ return;
+ } catch (error) {
+ console.error("Error processing the logo request:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/system/upload-pfp",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), handlePfpUpload],
+ async function (request, response) {
+ try {
+ const user = await userFromSession(request, response);
+ const uploadedFileName = request.randomFileName;
+ if (!uploadedFileName) {
+ return response.status(400).json({ message: "File upload failed." });
+ }
+
+ const userRecord = await User.get({ id: user.id });
+ const oldPfpFilename = userRecord.pfpFilename;
+ if (oldPfpFilename) {
+ const storagePath = path.join(__dirname, "../storage/assets/pfp");
+ const oldPfpPath = path.join(
+ storagePath,
+ normalizePath(userRecord.pfpFilename)
+ );
+ if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath)))
+ throw new Error("Invalid path name");
+ if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath);
+ }
+
+ const { success, error } = await User.update(user.id, {
+ pfpFilename: uploadedFileName,
+ });
+
+ return response.status(success ? 200 : 500).json({
+ message: success
+ ? "Profile picture uploaded successfully."
+ : error || "Failed to update with new profile picture.",
+ });
+ } catch (error) {
+ console.error("Error processing the profile picture upload:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.delete(
+ "/system/remove-pfp",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async function (request, response) {
+ try {
+ const user = await userFromSession(request, response);
+ const userRecord = await User.get({ id: user.id });
+ const oldPfpFilename = userRecord.pfpFilename;
+
+ if (oldPfpFilename) {
+ const storagePath = path.join(__dirname, "../storage/assets/pfp");
+ const oldPfpPath = path.join(
+ storagePath,
+ normalizePath(oldPfpFilename)
+ );
+ if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath)))
+ throw new Error("Invalid path name");
+ if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath);
+ }
+
+ const { success, error } = await User.update(user.id, {
+ pfpFilename: null,
+ });
+
+ return response.status(success ? 200 : 500).json({
+ message: success
+ ? "Profile picture removed successfully."
+ : error || "Failed to remove profile picture.",
+ });
+ } catch (error) {
+ console.error("Error processing the profile picture removal:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/system/upload-logo",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ handleAssetUpload,
+ ],
+ async (request, response) => {
+ if (!request?.file || !request?.file.originalname) {
+ return response.status(400).json({ message: "No logo file provided." });
+ }
+
+ if (!validFilename(request.file.originalname)) {
+ return response.status(400).json({
+ message: "Invalid file name. Please choose a different file.",
+ });
+ }
+
+ try {
+ const newFilename = await renameLogoFile(request.file.originalname);
+ const existingLogoFilename = await SystemSettings.currentLogoFilename();
+ await removeCustomLogo(existingLogoFilename);
+
+ const { success, error } = await SystemSettings._updateSettings({
+ logo_filename: newFilename,
+ });
+
+ return response.status(success ? 200 : 500).json({
+ message: success
+ ? "Logo uploaded successfully."
+ : error || "Failed to update with new logo.",
+ });
+ } catch (error) {
+ console.error("Error processing the logo upload:", error);
+ response.status(500).json({ message: "Error uploading the logo." });
+ }
+ }
+ );
+
+ app.get("/system/is-default-logo", async (_, response) => {
+ try {
+ const currentLogoFilename = await SystemSettings.currentLogoFilename();
+ const isDefaultLogo =
+ !currentLogoFilename || currentLogoFilename === LOGO_FILENAME;
+ response.status(200).json({ isDefaultLogo });
+ } catch (error) {
+ console.error("Error processing the logo request:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ });
+
+ app.get(
+ "/system/remove-logo",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (_request, response) => {
+ try {
+ const currentLogoFilename = await SystemSettings.currentLogoFilename();
+ await removeCustomLogo(currentLogoFilename);
+ const { success, error } = await SystemSettings._updateSettings({
+ logo_filename: LOGO_FILENAME,
+ });
+
+ return response.status(success ? 200 : 500).json({
+ message: success
+ ? "Logo removed successfully."
+ : error || "Failed to update with new logo.",
+ });
+ } catch (error) {
+ console.error("Error processing the logo removal:", error);
+ response.status(500).json({ message: "Error removing the logo." });
+ }
+ }
+ );
+
+ app.get(
+ "/system/welcome-messages",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async function (_, response) {
+ try {
+ const welcomeMessages = await WelcomeMessages.getMessages();
+ response.status(200).json({ success: true, welcomeMessages });
+ } catch (error) {
+ console.error("Error fetching welcome messages:", error);
+ response
+ .status(500)
+ .json({ success: false, message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/system/set-welcome-messages",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { messages = [] } = reqBody(request);
+ if (!Array.isArray(messages)) {
+ return response.status(400).json({
+ success: false,
+ message: "Invalid message format. Expected an array of messages.",
+ });
+ }
+
+ await WelcomeMessages.saveAll(messages);
+ return response.status(200).json({
+ success: true,
+ message: "Welcome messages saved successfully.",
+ });
+ } catch (error) {
+ console.error("Error processing the welcome messages:", error);
+ response.status(500).json({
+ success: true,
+ message: "Error saving the welcome messages.",
+ });
+ }
+ }
+ );
+
+ app.get("/system/api-keys", [validatedRequest], async (_, response) => {
+ try {
+ if (response.locals.multiUserMode) {
+ return response.sendStatus(401).end();
+ }
+
+ const apiKeys = await ApiKey.where({});
+ return response.status(200).json({
+ apiKeys,
+ error: null,
+ });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({
+ apiKey: null,
+ error: "Could not find an API Key.",
+ });
+ }
+ });
+
+ app.post(
+ "/system/generate-api-key",
+ [validatedRequest],
+ async (_, response) => {
+ try {
+ if (response.locals.multiUserMode) {
+ return response.sendStatus(401).end();
+ }
+
+ const { apiKey, error } = await ApiKey.create();
+ await EventLogs.logEvent(
+ "api_key_created",
+ {},
+ response?.locals?.user?.id
+ );
+ return response.status(200).json({
+ apiKey,
+ error,
+ });
+ } catch (error) {
+ console.error(error);
+ response.status(500).json({
+ apiKey: null,
+ error: "Error generating api key.",
+ });
+ }
+ }
+ );
+
+ // TODO: This endpoint is replicated in the admin endpoints file.
+ // and should be consolidated to be a single endpoint with flexible role protection.
+ app.delete(
+ "/system/api-key/:id",
+ [validatedRequest],
+ async (request, response) => {
+ try {
+ if (response.locals.multiUserMode)
+ return response.sendStatus(401).end();
+ const { id } = request.params;
+ if (!id || isNaN(Number(id))) return response.sendStatus(400).end();
+
+ await ApiKey.delete({ id: Number(id) });
+ await EventLogs.logEvent(
+ "api_key_deleted",
+ { deletedBy: response.locals?.user?.username },
+ response?.locals?.user?.id
+ );
+ return response.status(200).end();
+ } catch (error) {
+ console.error(error);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/system/custom-models",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { provider, apiKey = null, basePath = null } = reqBody(request);
+ const { models, error } = await getCustomModels(
+ provider,
+ apiKey,
+ basePath
+ );
+ return response.status(200).json({
+ models,
+ error,
+ });
+ } catch (error) {
+ console.error(error);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/system/event-logs",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { offset = 0, limit = 10 } = reqBody(request);
+ const logs = await EventLogs.whereWithData({}, limit, offset * limit, {
+ id: "desc",
+ });
+ const totalLogs = await EventLogs.count();
+ const hasPages = totalLogs > (offset + 1) * limit;
+
+ response.status(200).json({ logs: logs, hasPages, totalLogs });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/system/event-logs",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (_, response) => {
+ try {
+ await EventLogs.delete();
+ await EventLogs.logEvent(
+ "event_logs_cleared",
+ {},
+ response?.locals?.user?.id
+ );
+ response.json({ success: true });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/system/workspace-chats",
+ [
+ chatHistoryViewable,
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ ],
+ async (request, response) => {
+ try {
+ const { offset = 0, limit = 20 } = reqBody(request);
+ const chats = await WorkspaceChats.whereWithData(
+ {},
+ limit,
+ offset * limit,
+ { id: "desc" }
+ );
+ const totalChats = await WorkspaceChats.count();
+ const hasPages = totalChats > (offset + 1) * limit;
+
+ response.status(200).json({ chats: chats, hasPages, totalChats });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/system/workspace-chats/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ Number(id) === -1
+ ? await WorkspaceChats.delete({}, true)
+ : await WorkspaceChats.delete({ id: Number(id) });
+ response.json({ success: true, error: null });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/system/export-chats",
+ [
+ chatHistoryViewable,
+ validatedRequest,
+ flexUserRoleValid([ROLES.manager, ROLES.admin]),
+ ],
+ async (request, response) => {
+ try {
+ const { type = "jsonl", chatType = "workspace" } = request.query;
+ const { contentType, data } = await exportChatsAsType(type, chatType);
+ await EventLogs.logEvent(
+ "exported_chats",
+ {
+ type,
+ chatType,
+ },
+ response.locals.user?.id
+ );
+ response.setHeader("Content-Type", contentType);
+ response.status(200).send(data);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ // Used for when a user in multi-user updates their own profile
+ // from the UI.
+ app.post("/system/user", [validatedRequest], async (request, response) => {
+ try {
+ const sessionUser = await userFromSession(request, response);
+ const { username, password, bio } = reqBody(request);
+ const id = Number(sessionUser.id);
+
+ if (!id) {
+ response.status(400).json({ success: false, error: "Invalid user ID" });
+ return;
+ }
+
+ const updates = {};
+ if (username)
+ updates.username = User.validations.username(String(username));
+ if (password) updates.password = String(password);
+ if (bio) updates.bio = String(bio);
+
+ if (Object.keys(updates).length === 0) {
+ response
+ .status(400)
+ .json({ success: false, error: "No updates provided" });
+ return;
+ }
+
+ const { success, error } = await User.update(id, updates);
+ response.status(200).json({ success, error });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+
+ app.get(
+ "/system/slash-command-presets",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const userPresets = await SlashCommandPresets.getUserPresets(user?.id);
+ response.status(200).json({ presets: userPresets });
+ } catch (error) {
+ console.error("Error fetching slash command presets:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/system/slash-command-presets",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { command, prompt, description } = reqBody(request);
+ const formattedCommand = SlashCommandPresets.formatCommand(
+ String(command)
+ );
+
+ if (Object.keys(VALID_COMMANDS).includes(formattedCommand)) {
+ return response.status(400).json({
+ message:
+ "Cannot create a preset with a command that matches a system command",
+ });
+ }
+
+ const presetData = {
+ command: formattedCommand,
+ prompt: String(prompt),
+ description: String(description),
+ };
+
+ const preset = await SlashCommandPresets.create(user?.id, presetData);
+ if (!preset) {
+ return response
+ .status(500)
+ .json({ message: "Failed to create preset" });
+ }
+ response.status(201).json({ preset });
+ } catch (error) {
+ console.error("Error creating slash command preset:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/system/slash-command-presets/:slashCommandId",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { slashCommandId } = request.params;
+ const { command, prompt, description } = reqBody(request);
+ const formattedCommand = SlashCommandPresets.formatCommand(
+ String(command)
+ );
+
+ if (Object.keys(VALID_COMMANDS).includes(formattedCommand)) {
+ return response.status(400).json({
+ message:
+ "Cannot update a preset to use a command that matches a system command",
+ });
+ }
+
+ // Valid user running owns the preset if user session is valid.
+ const ownsPreset = await SlashCommandPresets.get({
+ userId: user?.id ?? null,
+ id: Number(slashCommandId),
+ });
+ if (!ownsPreset)
+ return response.status(404).json({ message: "Preset not found" });
+
+ const updates = {
+ command: formattedCommand,
+ prompt: String(prompt),
+ description: String(description),
+ };
+
+ const preset = await SlashCommandPresets.update(
+ Number(slashCommandId),
+ updates
+ );
+ if (!preset) return response.sendStatus(422);
+ response.status(200).json({ preset: { ...ownsPreset, ...updates } });
+ } catch (error) {
+ console.error("Error updating slash command preset:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.delete(
+ "/system/slash-command-presets/:slashCommandId",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const { slashCommandId } = request.params;
+ const user = await userFromSession(request, response);
+
+ // Valid user running owns the preset if user session is valid.
+ const ownsPreset = await SlashCommandPresets.get({
+ userId: user?.id ?? null,
+ id: Number(slashCommandId),
+ });
+ if (!ownsPreset)
+ return response
+ .status(403)
+ .json({ message: "Failed to delete preset" });
+
+ await SlashCommandPresets.delete(Number(slashCommandId));
+ response.sendStatus(204);
+ } catch (error) {
+ console.error("Error deleting slash command preset:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.get(
+ "/system/prompt-variables",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const variables = await SystemPromptVariables.getAll(user?.id);
+ response.status(200).json({ variables });
+ } catch (error) {
+ console.error("Error fetching system prompt variables:", error);
+ response.status(500).json({
+ success: false,
+ error: `Failed to fetch system prompt variables: ${error.message}`,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/system/prompt-variables",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { key, value, description = null } = reqBody(request);
+
+ if (!key || !value) {
+ return response.status(400).json({
+ success: false,
+ error: "Key and value are required",
+ });
+ }
+
+ const variable = await SystemPromptVariables.create({
+ key,
+ value,
+ description,
+ userId: user?.id || null,
+ });
+
+ response.status(200).json({
+ success: true,
+ variable,
+ });
+ } catch (error) {
+ console.error("Error creating system prompt variable:", error);
+ response.status(500).json({
+ success: false,
+ error: `Failed to create system prompt variable: ${error.message}`,
+ });
+ }
+ }
+ );
+
+ app.put(
+ "/system/prompt-variables/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ const { key, value, description = null } = reqBody(request);
+
+ if (!key || !value) {
+ return response.status(400).json({
+ success: false,
+ error: "Key and value are required",
+ });
+ }
+
+ const variable = await SystemPromptVariables.update(Number(id), {
+ key,
+ value,
+ description,
+ });
+
+ if (!variable) {
+ return response.status(404).json({
+ success: false,
+ error: "Variable not found",
+ });
+ }
+
+ response.status(200).json({
+ success: true,
+ variable,
+ });
+ } catch (error) {
+ console.error("Error updating system prompt variable:", error);
+ response.status(500).json({
+ success: false,
+ error: `Failed to update system prompt variable: ${error.message}`,
+ });
+ }
+ }
+ );
+
+ app.delete(
+ "/system/prompt-variables/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ const success = await SystemPromptVariables.delete(Number(id));
+
+ if (!success) {
+ return response.status(404).json({
+ success: false,
+ error: "System prompt variable not found or could not be deleted",
+ });
+ }
+
+ response.status(200).json({
+ success: true,
+ });
+ } catch (error) {
+ console.error("Error deleting system prompt variable:", error);
+ response.status(500).json({
+ success: false,
+ error: `Failed to delete system prompt variable: ${error.message}`,
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/system/validate-sql-connection",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ async (request, response) => {
+ try {
+ const { engine, connectionString } = reqBody(request);
+ if (!engine || !connectionString) {
+ return response.status(400).json({
+ success: false,
+ error: "Both engine and connection details are required.",
+ });
+ }
+
+ const {
+ validateConnection,
+ } = require("../utils/agents/aibitat/plugins/sql-agent/SQLConnectors");
+ const result = await validateConnection(engine, { connectionString });
+
+ if (!result.success) {
+ return response.status(200).json({
+ success: false,
+ error: `Unable to connect to ${engine}. Please verify your connection details.`,
+ });
+ }
+
+ response.status(200).json(result);
+ } catch (error) {
+ console.error("SQL validation error:", error);
+ response.status(500).json({
+ success: false,
+ error: `Unable to connect to ${engine}. Please verify your connection details.`,
+ });
+ }
+ }
+ );
+}
+
+module.exports = { systemEndpoints };
diff --git a/server/endpoints/utils.js b/server/endpoints/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..0bb51b830520686e3ad2184c2fb10b4d588ca076
--- /dev/null
+++ b/server/endpoints/utils.js
@@ -0,0 +1,183 @@
+const { SystemSettings } = require("../models/systemSettings");
+
+function utilEndpoints(app) {
+ if (!app) return;
+
+ app.get("/utils/metrics", async (_, response) => {
+ try {
+ const metrics = {
+ online: true,
+ version: getGitVersion(),
+ mode: (await SystemSettings.isMultiUserMode())
+ ? "multi-user"
+ : "single-user",
+ vectorDB: process.env.VECTOR_DB || "lancedb",
+ storage: await getDiskStorage(),
+ appVersion: getDeploymentVersion(),
+ };
+ response.status(200).json(metrics);
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ });
+}
+
+function getGitVersion() {
+ if (process.env.ANYTHING_LLM_RUNTIME === "docker") return "--";
+ try {
+ return require("child_process")
+ .execSync("git rev-parse HEAD")
+ .toString()
+ .trim();
+ } catch (e) {
+ console.error("getGitVersion", e.message);
+ return "--";
+ }
+}
+
+function byteToGigaByte(n) {
+ return n / Math.pow(10, 9);
+}
+
+async function getDiskStorage() {
+ try {
+ const checkDiskSpace = require("check-disk-space").default;
+ const { free, size } = await checkDiskSpace("/");
+ return {
+ current: Math.floor(byteToGigaByte(free)),
+ capacity: Math.floor(byteToGigaByte(size)),
+ };
+ } catch {
+ return {
+ current: null,
+ capacity: null,
+ };
+ }
+}
+
+/**
+ * Returns the model tag based on the provider set in the environment.
+ * This information is used to identify the parent model for the system
+ * so that we can prioritize the correct model and types for future updates
+ * as well as build features in AnythingLLM directly for a specific model or capabilities.
+ *
+ * Disable with {@link https://github.com/Mintplex-Labs/anything-llm?tab=readme-ov-file#telemetry--privacy|Disable Telemetry}
+ * @returns {string} The model tag.
+ */
+function getModelTag() {
+ let model = null;
+ const provider = process.env.LLM_PROVIDER;
+
+ switch (provider) {
+ case "openai":
+ model = process.env.OPEN_MODEL_PREF;
+ break;
+ case "anthropic":
+ model = process.env.ANTHROPIC_MODEL_PREF;
+ break;
+ case "lmstudio":
+ model = process.env.LMSTUDIO_MODEL_PREF;
+ break;
+ case "ollama":
+ model = process.env.OLLAMA_MODEL_PREF;
+ break;
+ case "groq":
+ model = process.env.GROQ_MODEL_PREF;
+ break;
+ case "togetherai":
+ model = process.env.TOGETHER_AI_MODEL_PREF;
+ break;
+ case "azure":
+ model = process.env.OPEN_MODEL_PREF;
+ break;
+ case "koboldcpp":
+ model = process.env.KOBOLD_CPP_MODEL_PREF;
+ break;
+ case "localai":
+ model = process.env.LOCAL_AI_MODEL_PREF;
+ break;
+ case "openrouter":
+ model = process.env.OPENROUTER_MODEL_PREF;
+ break;
+ case "mistral":
+ model = process.env.MISTRAL_MODEL_PREF;
+ break;
+ case "generic-openai":
+ model = process.env.GENERIC_OPEN_AI_MODEL_PREF;
+ break;
+ case "perplexity":
+ model = process.env.PERPLEXITY_MODEL_PREF;
+ break;
+ case "textgenwebui":
+ model = "textgenwebui-default";
+ break;
+ case "bedrock":
+ model = process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE;
+ break;
+ case "fireworksai":
+ model = process.env.FIREWORKS_AI_LLM_MODEL_PREF;
+ break;
+ case "deepseek":
+ model = process.env.DEEPSEEK_MODEL_PREF;
+ break;
+ case "litellm":
+ model = process.env.LITE_LLM_MODEL_PREF;
+ break;
+ case "apipie":
+ model = process.env.APIPIE_LLM_MODEL_PREF;
+ break;
+ case "xai":
+ model = process.env.XAI_LLM_MODEL_PREF;
+ break;
+ case "novita":
+ model = process.env.NOVITA_LLM_MODEL_PREF;
+ break;
+ case "nvidia-nim":
+ model = process.env.NVIDIA_NIM_LLM_MODEL_PREF;
+ break;
+ case "ppio":
+ model = process.env.PPIO_MODEL_PREF;
+ break;
+ case "gemini":
+ model = process.env.GEMINI_LLM_MODEL_PREF;
+ break;
+ case "moonshotai":
+ model = process.env.MOONSHOT_AI_MODEL_PREF;
+ break;
+ default:
+ model = "--";
+ break;
+ }
+ return model;
+}
+
+/**
+ * Returns the deployment version.
+ * - Dev: reads from package.json
+ * - Prod: reads from ENV
+ * expected format: major.minor.patch
+ * @returns {string|null} The deployment version.
+ */
+function getDeploymentVersion() {
+ if (process.env.NODE_ENV === "development")
+ return require("../../package.json").version;
+ if (process.env.DEPLOYMENT_VERSION) return process.env.DEPLOYMENT_VERSION;
+ return null;
+}
+
+/**
+ * Returns the user agent for the AnythingLLM deployment.
+ * @returns {string} The user agent.
+ */
+function getAnythingLLMUserAgent() {
+ const version = getDeploymentVersion() || "unknown";
+ return `AnythingLLM/${version}`;
+}
+
+module.exports = {
+ utilEndpoints,
+ getGitVersion,
+ getModelTag,
+ getAnythingLLMUserAgent,
+};
diff --git a/server/endpoints/workspaceThreads.js b/server/endpoints/workspaceThreads.js
new file mode 100644
index 0000000000000000000000000000000000000000..a34616cfa7f733293acc9bd7a3077f983262ccbc
--- /dev/null
+++ b/server/endpoints/workspaceThreads.js
@@ -0,0 +1,253 @@
+const {
+ multiUserMode,
+ userFromSession,
+ reqBody,
+ safeJsonParse,
+} = require("../utils/http");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { Telemetry } = require("../models/telemetry");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { EventLogs } = require("../models/eventLogs");
+const { WorkspaceThread } = require("../models/workspaceThread");
+const {
+ validWorkspaceSlug,
+ validWorkspaceAndThreadSlug,
+} = require("../utils/middleware/validWorkspace");
+const { WorkspaceChats } = require("../models/workspaceChats");
+const { convertToChatHistory } = require("../utils/helpers/chat/responses");
+const { getModelTag } = require("./utils");
+
+function workspaceThreadEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/workspace/:slug/thread/new",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const { thread, message } = await WorkspaceThread.new(
+ workspace,
+ user?.id
+ );
+ await Telemetry.sendTelemetry(
+ "workspace_thread_created",
+ {
+ multiUserMode: multiUserMode(response),
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ },
+ user?.id
+ );
+
+ await EventLogs.logEvent(
+ "workspace_thread_created",
+ {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ },
+ user?.id
+ );
+ response.status(200).json({ thread, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug/threads",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const threads = await WorkspaceThread.where({
+ workspace_id: workspace.id,
+ user_id: user?.id || null,
+ });
+ response.status(200).json({ threads });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/thread/:threadSlug",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.all]),
+ validWorkspaceAndThreadSlug,
+ ],
+ async (_, response) => {
+ try {
+ const thread = response.locals.thread;
+ await WorkspaceThread.delete({ id: thread.id });
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/thread-bulk-delete",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const { slugs = [] } = reqBody(request);
+ if (slugs.length === 0) return response.sendStatus(200).end();
+
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ await WorkspaceThread.delete({
+ slug: { in: slugs },
+ user_id: user?.id ?? null,
+ workspace_id: workspace.id,
+ });
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug/thread/:threadSlug/chats",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.all]),
+ validWorkspaceAndThreadSlug,
+ ],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const thread = response.locals.thread;
+ const history = await WorkspaceChats.where(
+ {
+ workspaceId: workspace.id,
+ user_id: user?.id || null,
+ thread_id: thread.id,
+ api_session_id: null, // Do not include API session chats.
+ include: true,
+ },
+ null,
+ { id: "asc" }
+ );
+
+ response.status(200).json({ history: convertToChatHistory(history) });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/thread/:threadSlug/update",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.all]),
+ validWorkspaceAndThreadSlug,
+ ],
+ async (request, response) => {
+ try {
+ const data = reqBody(request);
+ const currentThread = response.locals.thread;
+ const { thread, message } = await WorkspaceThread.update(
+ currentThread,
+ data
+ );
+ response.status(200).json({ thread, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/thread/:threadSlug/delete-edited-chats",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.all]),
+ validWorkspaceAndThreadSlug,
+ ],
+ async (request, response) => {
+ try {
+ const { startingId } = reqBody(request);
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const thread = response.locals.thread;
+
+ await WorkspaceChats.delete({
+ workspaceId: Number(workspace.id),
+ thread_id: Number(thread.id),
+ user_id: user?.id,
+ id: { gte: Number(startingId) },
+ });
+
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/thread/:threadSlug/update-chat",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.all]),
+ validWorkspaceAndThreadSlug,
+ ],
+ async (request, response) => {
+ try {
+ const { chatId, newText = null } = reqBody(request);
+ if (!newText || !String(newText).trim())
+ throw new Error("Cannot save empty response");
+
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const thread = response.locals.thread;
+ const existingChat = await WorkspaceChats.get({
+ workspaceId: workspace.id,
+ thread_id: thread.id,
+ user_id: user?.id,
+ id: Number(chatId),
+ });
+ if (!existingChat) throw new Error("Invalid chat.");
+
+ const chatResponse = safeJsonParse(existingChat.response, null);
+ if (!chatResponse) throw new Error("Failed to parse chat response");
+
+ await WorkspaceChats._update(existingChat.id, {
+ response: JSON.stringify({
+ ...chatResponse,
+ text: String(newText),
+ }),
+ });
+
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { workspaceThreadEndpoints };
diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js
new file mode 100644
index 0000000000000000000000000000000000000000..af4eb9983b39c92f7f23ffaeb45d84169d97b52e
--- /dev/null
+++ b/server/endpoints/workspaces.js
@@ -0,0 +1,1069 @@
+const path = require("path");
+const fs = require("fs");
+const {
+ reqBody,
+ multiUserMode,
+ userFromSession,
+ safeJsonParse,
+} = require("../utils/http");
+const { normalizePath, isWithin } = require("../utils/files");
+const { Workspace } = require("../models/workspace");
+const { Document } = require("../models/documents");
+const { DocumentVectors } = require("../models/vectors");
+const { WorkspaceChats } = require("../models/workspaceChats");
+const { getVectorDbClass } = require("../utils/helpers");
+const { handleFileUpload, handlePfpUpload } = require("../utils/files/multer");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { Telemetry } = require("../models/telemetry");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { EventLogs } = require("../models/eventLogs");
+const {
+ WorkspaceSuggestedMessages,
+} = require("../models/workspacesSuggestedMessages");
+const { validWorkspaceSlug } = require("../utils/middleware/validWorkspace");
+const { convertToChatHistory } = require("../utils/helpers/chat/responses");
+const { CollectorApi } = require("../utils/collectorApi");
+const {
+ determineWorkspacePfpFilepath,
+ fetchPfp,
+} = require("../utils/files/pfp");
+const { getTTSProvider } = require("../utils/TextToSpeech");
+const { WorkspaceThread } = require("../models/workspaceThread");
+
+const truncate = require("truncate");
+const { purgeDocument } = require("../utils/files/purgeDocument");
+const { getModelTag } = require("./utils");
+const { searchWorkspaceAndThreads } = require("../utils/helpers/search");
+const { workspaceParsedFilesEndpoints } = require("./workspacesParsedFiles");
+
+function workspaceEndpoints(app) {
+ if (!app) return;
+ const responseCache = new Map();
+
+ app.post(
+ "/workspace/new",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { name = null, onboardingComplete = false } = reqBody(request);
+ const { workspace, message } = await Workspace.new(name, user?.id);
+ await Telemetry.sendTelemetry(
+ "workspace_created",
+ {
+ multiUserMode: multiUserMode(response),
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ },
+ user?.id
+ );
+
+ await EventLogs.logEvent(
+ "workspace_created",
+ {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ },
+ user?.id
+ );
+ if (onboardingComplete === true)
+ await Telemetry.sendTelemetry("onboarding_complete");
+
+ response.status(200).json({ workspace, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/update",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { slug = null } = request.params;
+ const data = reqBody(request);
+ const currWorkspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ if (!currWorkspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ await Workspace.trackChange(currWorkspace, data, user);
+ const { workspace, message } = await Workspace.update(
+ currWorkspace.id,
+ data
+ );
+ response.status(200).json({ workspace, message });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/upload",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ handleFileUpload,
+ ],
+ async function (request, response) {
+ try {
+ const Collector = new CollectorApi();
+ const { originalname } = request.file;
+ const processingOnline = await Collector.online();
+
+ if (!processingOnline) {
+ response
+ .status(500)
+ .json({
+ success: false,
+ error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`,
+ })
+ .end();
+ return;
+ }
+
+ const { success, reason } =
+ await Collector.processDocument(originalname);
+ if (!success) {
+ response.status(500).json({ success: false, error: reason }).end();
+ return;
+ }
+
+ Collector.log(
+ `Document ${originalname} uploaded processed and successfully. It is now available in documents.`
+ );
+ await Telemetry.sendTelemetry("document_uploaded");
+ await EventLogs.logEvent(
+ "document_uploaded",
+ {
+ documentName: originalname,
+ },
+ response.locals?.user?.id
+ );
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/upload-link",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const Collector = new CollectorApi();
+ const { link = "" } = reqBody(request);
+ const processingOnline = await Collector.online();
+
+ if (!processingOnline) {
+ response
+ .status(500)
+ .json({
+ success: false,
+ error: `Document processing API is not online. Link ${link} will not be processed automatically.`,
+ })
+ .end();
+ return;
+ }
+
+ const { success, reason } = await Collector.processLink(link);
+ if (!success) {
+ response.status(500).json({ success: false, error: reason }).end();
+ return;
+ }
+
+ Collector.log(
+ `Link ${link} uploaded processed and successfully. It is now available in documents.`
+ );
+ await Telemetry.sendTelemetry("link_uploaded");
+ await EventLogs.logEvent(
+ "link_uploaded",
+ { link },
+ response.locals?.user?.id
+ );
+ response.status(200).json({ success: true, error: null });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/update-embeddings",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const { slug = null } = request.params;
+ const { adds = [], deletes = [] } = reqBody(request);
+ const currWorkspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ if (!currWorkspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ await Document.removeDocuments(
+ currWorkspace,
+ deletes,
+ response.locals?.user?.id
+ );
+ const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
+ currWorkspace,
+ adds,
+ response.locals?.user?.id
+ );
+ const updatedWorkspace = await Workspace.get({ id: currWorkspace.id });
+ response.status(200).json({
+ workspace: updatedWorkspace,
+ message:
+ failedToEmbed.length > 0
+ ? `${failedToEmbed.length} documents failed to add.\n\n${errors
+ .map((msg) => `${msg}`)
+ .join("\n\n")}`
+ : null,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { slug = "" } = request.params;
+ const user = await userFromSession(request, response);
+ const VectorDb = getVectorDbClass();
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ await WorkspaceChats.delete({ workspaceId: Number(workspace.id) });
+ await DocumentVectors.deleteForWorkspace(workspace.id);
+ await Document.delete({ workspaceId: Number(workspace.id) });
+ await Workspace.delete({ id: Number(workspace.id) });
+
+ await EventLogs.logEvent(
+ "workspace_deleted",
+ {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ },
+ response.locals?.user?.id
+ );
+
+ try {
+ await VectorDb["delete-namespace"]({ namespace: slug });
+ } catch (e) {
+ console.error(e.message);
+ }
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/reset-vector-db",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { slug = "" } = request.params;
+ const user = await userFromSession(request, response);
+ const VectorDb = getVectorDbClass();
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ await DocumentVectors.deleteForWorkspace(workspace.id);
+ await Document.delete({ workspaceId: Number(workspace.id) });
+
+ await EventLogs.logEvent(
+ "workspace_vectors_reset",
+ {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ },
+ response.locals?.user?.id
+ );
+
+ try {
+ await VectorDb["delete-namespace"]({ namespace: slug });
+ } catch (e) {
+ console.error(e.message);
+ }
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspaces",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const workspaces = multiUserMode(response)
+ ? await Workspace.whereWithUser(user)
+ : await Workspace.where();
+
+ response.status(200).json({ workspaces });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const { slug } = request.params;
+ const user = await userFromSession(request, response);
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ response.status(200).json({ workspace });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug/chats",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const { slug } = request.params;
+ const user = await userFromSession(request, response);
+ const workspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ if (!workspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const history = multiUserMode(response)
+ ? await WorkspaceChats.forWorkspaceByUser(workspace.id, user.id)
+ : await WorkspaceChats.forWorkspace(workspace.id);
+ response.status(200).json({ history: convertToChatHistory(history) });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/delete-chats",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const { chatIds = [] } = reqBody(request);
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+
+ if (!workspace || !Array.isArray(chatIds)) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ // This works for both workspace and threads.
+ // we simplify this by just looking at workspace<>user overlap
+ // since they are all on the same table.
+ await WorkspaceChats.delete({
+ id: { in: chatIds.map((id) => Number(id)) },
+ user_id: user?.id ?? null,
+ workspaceId: workspace.id,
+ });
+
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/delete-edited-chats",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const { startingId } = reqBody(request);
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+
+ await WorkspaceChats.delete({
+ workspaceId: workspace.id,
+ thread_id: null,
+ user_id: user?.id,
+ id: { gte: Number(startingId) },
+ });
+
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/update-chat",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const { chatId, newText = null } = reqBody(request);
+ if (!newText || !String(newText).trim())
+ throw new Error("Cannot save empty response");
+
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const existingChat = await WorkspaceChats.get({
+ workspaceId: workspace.id,
+ thread_id: null,
+ user_id: user?.id,
+ id: Number(chatId),
+ });
+ if (!existingChat) throw new Error("Invalid chat.");
+
+ const chatResponse = safeJsonParse(existingChat.response, null);
+ if (!chatResponse) throw new Error("Failed to parse chat response");
+
+ await WorkspaceChats._update(existingChat.id, {
+ response: JSON.stringify({
+ ...chatResponse,
+ text: String(newText),
+ }),
+ });
+
+ response.sendStatus(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/chat-feedback/:chatId",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const { chatId } = request.params;
+ const { feedback = null } = reqBody(request);
+ const existingChat = await WorkspaceChats.get({
+ id: Number(chatId),
+ workspaceId: response.locals.workspace.id,
+ });
+
+ if (!existingChat) {
+ response.status(404).end();
+ return;
+ }
+
+ const result = await WorkspaceChats.updateFeedbackScore(
+ chatId,
+ feedback
+ );
+ response.status(200).json({ success: result });
+ } catch (error) {
+ console.error("Error updating chat feedback:", error);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug/suggested-messages",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async function (request, response) {
+ try {
+ const { slug } = request.params;
+ const suggestedMessages =
+ await WorkspaceSuggestedMessages.getMessages(slug);
+ response.status(200).json({ success: true, suggestedMessages });
+ } catch (error) {
+ console.error("Error fetching suggested messages:", error);
+ response
+ .status(500)
+ .json({ success: false, message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/suggested-messages",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const { messages = [] } = reqBody(request);
+ const { slug } = request.params;
+ if (!Array.isArray(messages)) {
+ return response.status(400).json({
+ success: false,
+ message: "Invalid message format. Expected an array of messages.",
+ });
+ }
+
+ await WorkspaceSuggestedMessages.saveAll(messages, slug);
+ return response.status(200).json({
+ success: true,
+ message: "Suggested messages saved successfully.",
+ });
+ } catch (error) {
+ console.error("Error processing the suggested messages:", error);
+ response.status(500).json({
+ success: true,
+ message: "Error saving the suggested messages.",
+ });
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/update-pin",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ validWorkspaceSlug,
+ ],
+ async (request, response) => {
+ try {
+ const { docPath, pinStatus = false } = reqBody(request);
+ const workspace = response.locals.workspace;
+
+ const document = await Document.get({
+ workspaceId: workspace.id,
+ docpath: docPath,
+ });
+ if (!document) return response.sendStatus(404).end();
+
+ await Document.update(document.id, { pinned: pinStatus });
+ return response.status(200).end();
+ } catch (error) {
+ console.error("Error processing the pin status update:", error);
+ return response.status(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug/tts/:chatId",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async function (request, response) {
+ try {
+ const { chatId } = request.params;
+ const workspace = response.locals.workspace;
+ const cacheKey = `${workspace.slug}:${chatId}`;
+ const wsChat = await WorkspaceChats.get({
+ id: Number(chatId),
+ workspaceId: workspace.id,
+ });
+
+ const cachedResponse = responseCache.get(cacheKey);
+ if (cachedResponse) {
+ response.writeHead(200, {
+ "Content-Type": cachedResponse.mime || "audio/mpeg",
+ });
+ response.end(cachedResponse.buffer);
+ return;
+ }
+
+ const text = safeJsonParse(wsChat.response, null)?.text;
+ if (!text) return response.sendStatus(204).end();
+
+ const TTSProvider = getTTSProvider();
+ const buffer = await TTSProvider.ttsBuffer(text);
+ if (buffer === null) return response.sendStatus(204).end();
+
+ responseCache.set(cacheKey, { buffer, mime: "audio/mpeg" });
+ response.writeHead(200, {
+ "Content-Type": "audio/mpeg",
+ });
+ response.end(buffer);
+ return;
+ } catch (error) {
+ console.error("Error processing the TTS request:", error);
+ response.status(500).json({ message: "TTS could not be completed" });
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug/pfp",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async function (request, response) {
+ try {
+ const { slug } = request.params;
+ const cachedResponse = responseCache.get(slug);
+
+ if (cachedResponse) {
+ response.writeHead(200, {
+ "Content-Type": cachedResponse.mime || "image/png",
+ });
+ response.end(cachedResponse.buffer);
+ return;
+ }
+
+ const pfpPath = await determineWorkspacePfpFilepath(slug);
+
+ if (!pfpPath) {
+ response.sendStatus(204).end();
+ return;
+ }
+
+ const { found, buffer, mime } = fetchPfp(pfpPath);
+ if (!found) {
+ response.sendStatus(204).end();
+ return;
+ }
+
+ responseCache.set(slug, { buffer, mime });
+
+ response.writeHead(200, {
+ "Content-Type": mime || "image/png",
+ });
+ response.end(buffer);
+ return;
+ } catch (error) {
+ console.error("Error processing the logo request:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/upload-pfp",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ handlePfpUpload,
+ ],
+ async function (request, response) {
+ try {
+ const { slug } = request.params;
+ const uploadedFileName = request.randomFileName;
+ if (!uploadedFileName) {
+ return response.status(400).json({ message: "File upload failed." });
+ }
+
+ const workspaceRecord = await Workspace.get({
+ slug,
+ });
+
+ const oldPfpFilename = workspaceRecord.pfpFilename;
+ if (oldPfpFilename) {
+ const storagePath = path.join(__dirname, "../storage/assets/pfp");
+ const oldPfpPath = path.join(
+ storagePath,
+ normalizePath(workspaceRecord.pfpFilename)
+ );
+ if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath)))
+ throw new Error("Invalid path name");
+ if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath);
+ }
+
+ const { workspace, message } = await Workspace._update(
+ workspaceRecord.id,
+ {
+ pfpFilename: uploadedFileName,
+ }
+ );
+
+ return response.status(workspace ? 200 : 500).json({
+ message: workspace
+ ? "Profile picture uploaded successfully."
+ : message,
+ });
+ } catch (error) {
+ console.error("Error processing the profile picture upload:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/remove-pfp",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async function (request, response) {
+ try {
+ const { slug } = request.params;
+ const workspaceRecord = await Workspace.get({
+ slug,
+ });
+ const oldPfpFilename = workspaceRecord.pfpFilename;
+
+ if (oldPfpFilename) {
+ const storagePath = path.join(__dirname, "../storage/assets/pfp");
+ const oldPfpPath = path.join(
+ storagePath,
+ normalizePath(oldPfpFilename)
+ );
+ if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath)))
+ throw new Error("Invalid path name");
+ if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath);
+ }
+
+ const { workspace, message } = await Workspace._update(
+ workspaceRecord.id,
+ {
+ pfpFilename: null,
+ }
+ );
+
+ // Clear the cache
+ responseCache.delete(slug);
+
+ return response.status(workspace ? 200 : 500).json({
+ message: workspace
+ ? "Profile picture removed successfully."
+ : message,
+ });
+ } catch (error) {
+ console.error("Error processing the profile picture removal:", error);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/thread/fork",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const { chatId, threadSlug } = reqBody(request);
+ if (!chatId)
+ return response.status(400).json({ message: "chatId is required" });
+
+ // Get threadId we are branching from if that request body is sent
+ // and is a valid thread slug.
+ const threadId = !!threadSlug
+ ? (
+ await WorkspaceThread.get({
+ slug: String(threadSlug),
+ workspace_id: workspace.id,
+ })
+ )?.id ?? null
+ : null;
+ const chatsToFork = await WorkspaceChats.where(
+ {
+ workspaceId: workspace.id,
+ user_id: user?.id,
+ include: true, // only duplicate visible chats
+ thread_id: threadId,
+ api_session_id: null, // Do not include API session chats.
+ id: { lte: Number(chatId) },
+ },
+ null,
+ { id: "asc" }
+ );
+
+ const { thread: newThread, message: threadError } =
+ await WorkspaceThread.new(workspace, user?.id);
+ if (threadError)
+ return response.status(500).json({ error: threadError });
+
+ let lastMessageText = "";
+ const chatsData = chatsToFork.map((chat) => {
+ const chatResponse = safeJsonParse(chat.response, {});
+ if (chatResponse?.text) lastMessageText = chatResponse.text;
+
+ return {
+ workspaceId: workspace.id,
+ prompt: chat.prompt,
+ response: JSON.stringify(chatResponse),
+ user_id: user?.id,
+ thread_id: newThread.id,
+ };
+ });
+ await WorkspaceChats.bulkCreate(chatsData);
+ await WorkspaceThread.update(newThread, {
+ name: !!lastMessageText
+ ? truncate(lastMessageText, 22)
+ : "Forked Thread",
+ });
+
+ await EventLogs.logEvent(
+ "thread_forked",
+ {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ threadName: newThread.name,
+ },
+ user?.id
+ );
+ response.status(200).json({ newThreadSlug: newThread.slug });
+ } catch (e) {
+ console.error(e.message, e);
+ response.status(500).json({ message: "Internal server error" });
+ }
+ }
+ );
+
+ app.put(
+ "/workspace/workspace-chats/:id",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ const user = await userFromSession(request, response);
+ const validChat = await WorkspaceChats.get({
+ id: Number(id),
+ user_id: user?.id ?? null,
+ });
+ if (!validChat)
+ return response
+ .status(404)
+ .json({ success: false, error: "Chat not found." });
+
+ await WorkspaceChats._update(validChat.id, { include: false });
+ response.json({ success: true, error: null });
+ } catch (e) {
+ console.error(e.message, e);
+ response.status(500).json({ success: false, error: "Server error" });
+ }
+ }
+ );
+
+ /** Handles the uploading and embedding in one-call by uploading via drag-and-drop in chat container. */
+ app.post(
+ "/workspace/:slug/upload-and-embed",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ handleFileUpload,
+ ],
+ async function (request, response) {
+ try {
+ const { slug = null } = request.params;
+ const user = await userFromSession(request, response);
+ const currWorkspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ if (!currWorkspace) {
+ response.sendStatus(400).end();
+ return;
+ }
+
+ const Collector = new CollectorApi();
+ const { originalname } = request.file;
+ const processingOnline = await Collector.online();
+
+ if (!processingOnline) {
+ response
+ .status(500)
+ .json({
+ success: false,
+ error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`,
+ })
+ .end();
+ return;
+ }
+
+ const { success, reason, documents } =
+ await Collector.processDocument(originalname);
+ if (!success || documents?.length === 0) {
+ response.status(500).json({ success: false, error: reason }).end();
+ return;
+ }
+
+ Collector.log(
+ `Document ${originalname} uploaded processed and successfully. It is now available in documents.`
+ );
+ await Telemetry.sendTelemetry("document_uploaded");
+ await EventLogs.logEvent(
+ "document_uploaded",
+ {
+ documentName: originalname,
+ },
+ response.locals?.user?.id
+ );
+
+ const document = documents[0];
+ const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
+ currWorkspace,
+ [document.location],
+ response.locals?.user?.id
+ );
+
+ if (failedToEmbed.length > 0)
+ return response
+ .status(200)
+ .json({ success: false, error: errors?.[0], document: null });
+
+ response.status(200).json({
+ success: true,
+ error: null,
+ document: { id: document.id, location: document.location },
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/remove-and-unembed",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ handleFileUpload,
+ ],
+ async function (request, response) {
+ try {
+ const { slug = null } = request.params;
+ const body = reqBody(request);
+ const user = await userFromSession(request, response);
+ const currWorkspace = multiUserMode(response)
+ ? await Workspace.getWithUser(user, { slug })
+ : await Workspace.get({ slug });
+
+ if (!currWorkspace || !body.documentLocation)
+ return response.sendStatus(400).end();
+
+ // Will delete the document from the entire system + wil unembed it.
+ await purgeDocument(body.documentLocation);
+ response.status(200).end();
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.get(
+ "/workspace/:slug/prompt-history",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (_, response) => {
+ try {
+ response.status(200).json({
+ history: await Workspace.promptHistory({
+ workspaceId: response.locals.workspace.id,
+ }),
+ });
+ } catch (error) {
+ console.error("Error fetching prompt history:", error);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/prompt-history",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ validWorkspaceSlug,
+ ],
+ async (_, response) => {
+ try {
+ response.status(200).json({
+ success: await Workspace.deleteAllPromptHistory({
+ workspaceId: response.locals.workspace.id,
+ }),
+ });
+ } catch (error) {
+ console.error("Error clearing prompt history:", error);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/prompt-history/:id",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ validWorkspaceSlug,
+ ],
+ async (request, response) => {
+ try {
+ const { id } = request.params;
+ response.status(200).json({
+ success: await Workspace.deletePromptHistory({
+ workspaceId: response.locals.workspace.id,
+ id: Number(id),
+ }),
+ });
+ } catch (error) {
+ console.error("Error deleting prompt history:", error);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ /**
+ * Searches for workspaces and threads by thread name or workspace name.
+ * Only returns assets owned by the user (if multi-user mode is enabled).
+ */
+ app.post(
+ "/workspace/search",
+ [validatedRequest, flexUserRoleValid([ROLES.all])],
+ async (request, response) => {
+ try {
+ const { searchTerm } = reqBody(request);
+ const searchResults = await searchWorkspaceAndThreads(
+ searchTerm,
+ response.locals?.user
+ );
+ response.status(200).json(searchResults);
+ } catch (error) {
+ console.error("Error searching for workspaces:", error);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ // Parsed Files in separate endpoint just to keep the workspace endpoints clean
+ workspaceParsedFilesEndpoints(app);
+}
+
+module.exports = { workspaceEndpoints };
diff --git a/server/endpoints/workspacesParsedFiles.js b/server/endpoints/workspacesParsedFiles.js
new file mode 100644
index 0000000000000000000000000000000000000000..fde289a72eadf8f43a5276a9ae8fe04b4b27899e
--- /dev/null
+++ b/server/endpoints/workspacesParsedFiles.js
@@ -0,0 +1,199 @@
+const { reqBody, multiUserMode, userFromSession } = require("../utils/http");
+const { handleFileUpload } = require("../utils/files/multer");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const { Telemetry } = require("../models/telemetry");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { EventLogs } = require("../models/eventLogs");
+const { validWorkspaceSlug } = require("../utils/middleware/validWorkspace");
+const { CollectorApi } = require("../utils/collectorApi");
+const { WorkspaceThread } = require("../models/workspaceThread");
+const { WorkspaceParsedFiles } = require("../models/workspaceParsedFiles");
+
+function workspaceParsedFilesEndpoints(app) {
+ if (!app) return;
+
+ app.get(
+ "/workspace/:slug/parsed-files",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async (request, response) => {
+ try {
+ const threadSlug = request.query.threadSlug || null;
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const thread = threadSlug
+ ? await WorkspaceThread.get({ slug: String(threadSlug) })
+ : null;
+ const { files, contextWindow, currentContextTokenCount } =
+ await WorkspaceParsedFiles.getContextMetadataAndLimits(
+ workspace,
+ thread || null,
+ multiUserMode(response) ? user : null
+ );
+
+ return response
+ .status(200)
+ .json({ files, contextWindow, currentContextTokenCount });
+ } catch (e) {
+ console.error(e.message, e);
+ return response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.delete(
+ "/workspace/:slug/delete-parsed-files",
+ [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
+ async function (request, response) {
+ try {
+ const { fileIds = [] } = reqBody(request);
+ if (!fileIds.length) return response.sendStatus(400).end();
+ const success = await WorkspaceParsedFiles.delete({
+ id: { in: fileIds.map((id) => parseInt(id)) },
+ });
+ return response.status(success ? 200 : 500).end();
+ } catch (e) {
+ console.error(e.message, e);
+ return response.sendStatus(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/embed-parsed-file/:fileId",
+ [
+ validatedRequest,
+ // Embed is still an admin/manager only feature
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ validWorkspaceSlug,
+ ],
+ async function (request, response) {
+ const { fileId = null } = request.params;
+ try {
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+
+ if (!fileId) return response.sendStatus(400).end();
+ const { success, error, document } =
+ await WorkspaceParsedFiles.moveToDocumentsAndEmbed(fileId, workspace);
+
+ if (!success) {
+ return response.status(500).json({
+ success: false,
+ error: error || "Failed to embed file",
+ });
+ }
+
+ await Telemetry.sendTelemetry("document_embedded");
+ await EventLogs.logEvent(
+ "document_embedded",
+ {
+ documentName: document?.name || "unknown",
+ workspaceId: workspace.id,
+ },
+ user?.id
+ );
+
+ return response.status(200).json({
+ success: true,
+ error: null,
+ document,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ return response.sendStatus(500).end();
+ } finally {
+ if (!fileId) return;
+ await WorkspaceParsedFiles.delete({ id: parseInt(fileId) });
+ }
+ }
+ );
+
+ app.post(
+ "/workspace/:slug/parse",
+ [
+ validatedRequest,
+ flexUserRoleValid([ROLES.all]),
+ handleFileUpload,
+ validWorkspaceSlug,
+ ],
+ async function (request, response) {
+ try {
+ const user = await userFromSession(request, response);
+ const workspace = response.locals.workspace;
+ const Collector = new CollectorApi();
+ const { originalname } = request.file;
+ const processingOnline = await Collector.online();
+
+ if (!processingOnline) {
+ return response.status(500).json({
+ success: false,
+ error: `Document processing API is not online. Document ${originalname} will not be parsed.`,
+ });
+ }
+
+ const { success, reason, documents } =
+ await Collector.parseDocument(originalname);
+ if (!success || !documents?.[0]) {
+ return response.status(500).json({
+ success: false,
+ error: reason || "No document returned from collector",
+ });
+ }
+
+ // Get thread ID if we have a slug
+ const { threadSlug = null } = reqBody(request);
+ const thread = threadSlug
+ ? await WorkspaceThread.get({
+ slug: String(threadSlug),
+ workspace_id: workspace.id,
+ user_id: user?.id || null,
+ })
+ : null;
+ const files = await Promise.all(
+ documents.map(async (doc) => {
+ const metadata = { ...doc };
+ // Strip out pageContent
+ delete metadata.pageContent;
+ const filename = `${originalname}-${doc.id}.json`;
+ const { file, error: dbError } = await WorkspaceParsedFiles.create({
+ filename,
+ workspaceId: workspace.id,
+ userId: user?.id || null,
+ threadId: thread?.id || null,
+ metadata: JSON.stringify(metadata),
+ tokenCountEstimate: doc.token_count_estimate || 0,
+ });
+
+ if (dbError) throw new Error(dbError);
+ return file;
+ })
+ );
+
+ Collector.log(`Document ${originalname} parsed successfully.`);
+ await EventLogs.logEvent(
+ "document_uploaded_to_chat",
+ {
+ documentName: originalname,
+ workspace: workspace.slug,
+ thread: thread?.name || null,
+ },
+ user?.id
+ );
+
+ return response.status(200).json({
+ success: true,
+ error: null,
+ files,
+ });
+ } catch (e) {
+ console.error(e.message, e);
+ return response.sendStatus(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { workspaceParsedFilesEndpoints };
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..1779035d552e6c1eb0418c511496bbb17fba8938
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,138 @@
+process.env.NODE_ENV === "development"
+ ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` })
+ : require("dotenv").config();
+
+require("./utils/logger")();
+const express = require("express");
+const bodyParser = require("body-parser");
+const cors = require("cors");
+const path = require("path");
+const { reqBody } = require("./utils/http");
+const { systemEndpoints } = require("./endpoints/system");
+const { workspaceEndpoints } = require("./endpoints/workspaces");
+const { chatEndpoints } = require("./endpoints/chat");
+const { embeddedEndpoints } = require("./endpoints/embed");
+const { embedManagementEndpoints } = require("./endpoints/embedManagement");
+const { getVectorDbClass } = require("./utils/helpers");
+const { adminEndpoints } = require("./endpoints/admin");
+const { inviteEndpoints } = require("./endpoints/invite");
+const { utilEndpoints } = require("./endpoints/utils");
+const { developerEndpoints } = require("./endpoints/api");
+const { extensionEndpoints } = require("./endpoints/extensions");
+const { bootHTTP, bootSSL } = require("./utils/boot");
+const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
+const { documentEndpoints } = require("./endpoints/document");
+const { agentWebsocket } = require("./endpoints/agentWebsocket");
+const { experimentalEndpoints } = require("./endpoints/experimental");
+const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
+const { communityHubEndpoints } = require("./endpoints/communityHub");
+const { agentFlowEndpoints } = require("./endpoints/agentFlows");
+const { mcpServersEndpoints } = require("./endpoints/mcpServers");
+const { mobileEndpoints } = require("./endpoints/mobile");
+const app = express();
+const apiRouter = express.Router();
+const FILE_LIMIT = "3GB";
+
+app.use(cors({ origin: true }));
+app.use(bodyParser.text({ limit: FILE_LIMIT }));
+app.use(bodyParser.json({ limit: FILE_LIMIT }));
+app.use(
+ bodyParser.urlencoded({
+ limit: FILE_LIMIT,
+ extended: true,
+ })
+);
+
+if (!!process.env.ENABLE_HTTPS) {
+ bootSSL(app, process.env.SERVER_PORT || 3001);
+} else {
+ require("@mintplex-labs/express-ws").default(app); // load WebSockets in non-SSL mode.
+}
+
+app.use("/api", apiRouter);
+systemEndpoints(apiRouter);
+extensionEndpoints(apiRouter);
+workspaceEndpoints(apiRouter);
+workspaceThreadEndpoints(apiRouter);
+chatEndpoints(apiRouter);
+adminEndpoints(apiRouter);
+inviteEndpoints(apiRouter);
+embedManagementEndpoints(apiRouter);
+utilEndpoints(apiRouter);
+documentEndpoints(apiRouter);
+agentWebsocket(apiRouter);
+experimentalEndpoints(apiRouter);
+developerEndpoints(app, apiRouter);
+communityHubEndpoints(apiRouter);
+agentFlowEndpoints(apiRouter);
+mcpServersEndpoints(apiRouter);
+mobileEndpoints(apiRouter);
+
+// Externally facing embedder endpoints
+embeddedEndpoints(apiRouter);
+
+// Externally facing browser extension endpoints
+browserExtensionEndpoints(apiRouter);
+
+if (process.env.NODE_ENV !== "development") {
+ const { MetaGenerator } = require("./utils/boot/MetaGenerator");
+ const IndexPage = new MetaGenerator();
+
+ app.use(
+ express.static(path.resolve(__dirname, "public"), {
+ extensions: ["js"],
+ setHeaders: (res) => {
+ // Disable I-framing of entire site UI
+ res.removeHeader("X-Powered-By");
+ res.setHeader("X-Frame-Options", "DENY");
+ },
+ })
+ );
+
+ app.use("/", function (_, response) {
+ IndexPage.generate(response);
+ return;
+ });
+
+ app.get("/robots.txt", function (_, response) {
+ response.type("text/plain");
+ response.send("User-agent: *\nDisallow: /").end();
+ });
+} else {
+ // Debug route for development connections to vectorDBs
+ apiRouter.post("/v/:command", async (request, response) => {
+ try {
+ const VectorDb = getVectorDbClass();
+ const { command } = request.params;
+ if (!Object.getOwnPropertyNames(VectorDb).includes(command)) {
+ response.status(500).json({
+ message: "invalid interface command",
+ commands: Object.getOwnPropertyNames(VectorDb),
+ });
+ return;
+ }
+
+ try {
+ const body = reqBody(request);
+ const resBody = await VectorDb[command](body);
+ response.status(200).json({ ...resBody });
+ } catch (e) {
+ // console.error(e)
+ console.error(JSON.stringify(e));
+ response.status(500).json({ error: e.message });
+ }
+ return;
+ } catch (e) {
+ console.error(e.message, e);
+ response.sendStatus(500).end();
+ }
+ });
+}
+
+app.all("*", function (_, response) {
+ response.sendStatus(404);
+});
+
+// In non-https mode we need to boot at the end since the server has not yet
+// started and is `.listen`ing.
+if (!process.env.ENABLE_HTTPS) bootHTTP(app, process.env.SERVER_PORT || 3001);
diff --git a/server/jobs/cleanup-orphan-documents.js b/server/jobs/cleanup-orphan-documents.js
new file mode 100644
index 0000000000000000000000000000000000000000..9a50fcf03343d89dd345e1732dba220d3ab75c5f
--- /dev/null
+++ b/server/jobs/cleanup-orphan-documents.js
@@ -0,0 +1,64 @@
+const fs = require('fs');
+const path = require('path');
+const { log, conclude } = require('./helpers/index.js');
+const { WorkspaceParsedFiles } = require('../models/workspaceParsedFiles.js');
+const { directUploadsPath } = require('../utils/files');
+
+async function batchDeleteFiles(filesToDelete, batchSize = 500) {
+ let deletedCount = 0;
+ let failedCount = 0;
+
+ for (let i = 0; i < filesToDelete.length; i += batchSize) {
+ const batch = filesToDelete.slice(i, i + batchSize);
+
+ try {
+ await Promise.all(batch.map(filePath => fs.unlink(filePath)));
+ deletedCount += batch.length;
+
+ log(`Deleted batch ${Math.floor(i / batchSize) + 1}: ${batch.length} files`);
+ } catch (err) {
+ // If batch fails, try individual files sync
+ for (const filePath of batch) {
+ try {
+ fs.unlinkSync(filePath);
+ deletedCount++;
+ } catch (fileErr) {
+ failedCount++;
+ log(`Failed to delete ${filePath}: ${fileErr.message}`);
+ }
+ }
+ }
+ }
+
+ return { deletedCount, failedCount };
+}
+
+(async () => {
+ try {
+ const filesToDelete = [];
+ const knownFiles = await WorkspaceParsedFiles
+ .where({}, null, null, { filename: true })
+ .then(files => new Set(files.map(f => f.filename)));
+
+ if (!fs.existsSync(directUploadsPath)) return log('No direct uploads path found - exiting.');
+ const filesInDirectUploadsPath = fs.readdirSync(directUploadsPath);
+ if (filesInDirectUploadsPath.length === 0) return;
+
+ for (let i = 0; i < filesInDirectUploadsPath.length; i++) {
+ const file = filesInDirectUploadsPath[i];
+ if (knownFiles.has(file)) continue;
+ filesToDelete.push(path.resolve(directUploadsPath, file));
+ }
+
+ if (filesToDelete.length === 0) return; // No orphaned files to delete
+ log(`Found ${filesToDelete.length} orphaned files to delete`);
+ const { deletedCount, failedCount } = await batchDeleteFiles(filesToDelete);
+ log(`Deleted ${deletedCount} orphaned files`);
+ if (failedCount > 0) log(`Failed to delete ${failedCount} files`);
+ } catch (e) {
+ console.error(e)
+ log(`errored with ${e.message}`)
+ } finally {
+ conclude();
+ }
+})();
diff --git a/server/jobs/helpers/index.js b/server/jobs/helpers/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e8d83f822c52ece4fe039ea452189595badd60cd
--- /dev/null
+++ b/server/jobs/helpers/index.js
@@ -0,0 +1,30 @@
+const path = require('node:path');
+const fs = require('node:fs');
+const { parentPort } = require('node:worker_threads');
+const documentsPath =
+ process.env.NODE_ENV === "development"
+ ? path.resolve(__dirname, `../../storage/documents`)
+ : path.resolve(process.env.STORAGE_DIR, `documents`);
+
+function log(stringContent = '') {
+ if (parentPort) parentPort.postMessage(`\x1b[33m[${process.pid}]\x1b[0m: ${stringContent}`); // running as worker
+ else process.send(`\x1b[33m[${process.ppid}:${process.pid}]\x1b[0m: ${stringContent}`); // running as child_process
+}
+
+function conclude() {
+ if (parentPort) parentPort.postMessage('done');
+ else process.exit(0);
+}
+
+function updateSourceDocument(docPath = null, jsonContent = {}) {
+ const destinationFilePath = path.resolve(documentsPath, docPath);
+ fs.writeFileSync(destinationFilePath, JSON.stringify(jsonContent, null, 4), {
+ encoding: "utf-8",
+ });
+}
+
+module.exports = {
+ log,
+ conclude,
+ updateSourceDocument,
+}
\ No newline at end of file
diff --git a/server/jobs/sync-watched-documents.js b/server/jobs/sync-watched-documents.js
new file mode 100644
index 0000000000000000000000000000000000000000..0b3a72d1d3d95faaab620c4dca4d82bda836ffd9
--- /dev/null
+++ b/server/jobs/sync-watched-documents.js
@@ -0,0 +1,153 @@
+const { Document } = require('../models/documents.js');
+const { DocumentSyncQueue } = require('../models/documentSyncQueue.js');
+const { CollectorApi } = require('../utils/collectorApi');
+const { fileData } = require("../utils/files");
+const { log, conclude, updateSourceDocument } = require('./helpers/index.js');
+const { getVectorDbClass } = require('../utils/helpers/index.js');
+const { DocumentSyncRun } = require('../models/documentSyncRun.js');
+
+(async () => {
+ try {
+ const queuesToProcess = await DocumentSyncQueue.staleDocumentQueues();
+ if (queuesToProcess.length === 0) {
+ log('No outstanding documents to sync. Exiting.');
+ return;
+ }
+
+ const collector = new CollectorApi();
+ if (!(await collector.online())) {
+ log('Could not reach collector API. Exiting.');
+ return;
+ }
+
+ log(`${queuesToProcess.length} watched documents have been found to be stale and will be updated now.`)
+ for (const queue of queuesToProcess) {
+ let newContent = null;
+ const document = queue.workspaceDoc;
+ const workspace = document.workspace;
+ const { metadata, type, source } = Document.parseDocumentTypeAndSource(document);
+
+ if (!metadata || !DocumentSyncQueue.validFileTypes.includes(type)) {
+ // Document is either broken, invalid, or not supported so drop it from future queues.
+ log(`Document ${document.filename} has no metadata, is broken, or invalid and has been removed from all future runs.`)
+ await DocumentSyncQueue.unwatch(document);
+ continue;
+ }
+
+ if (['link', 'youtube'].includes(type)) {
+ const response = await collector.forwardExtensionRequest({
+ endpoint: "/ext/resync-source-document",
+ method: "POST",
+ body: JSON.stringify({
+ type,
+ options: { link: source }
+ })
+ });
+ newContent = response?.content;
+ }
+
+ if (['confluence', 'github', 'gitlab', 'drupalwiki'].includes(type)) {
+ const response = await collector.forwardExtensionRequest({
+ endpoint: "/ext/resync-source-document",
+ method: "POST",
+ body: JSON.stringify({
+ type,
+ options: { chunkSource: metadata.chunkSource }
+ })
+ });
+ newContent = response?.content;
+ }
+
+ if (!newContent) {
+ // Check if the last "x" runs were all failures (not exits!). If so - remove the job entirely since it is broken.
+ const failedRunCount = (await DocumentSyncRun.where({ queueId: queue.id }, DocumentSyncQueue.maxRepeatFailures, { createdAt: 'desc' })).filter((run) => run.status === DocumentSyncRun.statuses.failed).length;
+ if (failedRunCount >= DocumentSyncQueue.maxRepeatFailures) {
+ log(`Document ${document.filename} has failed to refresh ${failedRunCount} times continuously and will now be removed from the watched document set.`)
+ await DocumentSyncQueue.unwatch(document);
+ continue;
+ }
+
+ log(`Failed to get a new content response from collector for source ${source}. Skipping, but will retry next worker interval. Attempt ${failedRunCount === 0 ? 1 : failedRunCount}/${DocumentSyncQueue.maxRepeatFailures}`);
+ await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.failed, { filename: document.filename, workspacesModified: [], reason: 'No content found.' })
+ continue;
+ }
+
+ const currentDocumentData = await fileData(document.docpath)
+ if (currentDocumentData.pageContent === newContent) {
+ const nextSync = DocumentSyncQueue.calcNextSync(queue)
+ log(`Source ${source} is unchanged and will be skipped. Next sync will be ${nextSync.toLocaleString()}.`);
+ await DocumentSyncQueue._update(
+ queue.id,
+ {
+ lastSyncedAt: new Date().toISOString(),
+ nextSyncAt: nextSync.toISOString(),
+ }
+ );
+ await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.exited, { filename: document.filename, workspacesModified: [], reason: 'Content unchanged.' })
+ continue;
+ }
+
+ // update the defined document and workspace vectorDB with the latest information
+ // it will skip cache and create a new vectorCache file.
+ const vectorDatabase = getVectorDbClass();
+ await vectorDatabase.deleteDocumentFromNamespace(workspace.slug, document.docId);
+ await vectorDatabase.addDocumentToNamespace(
+ workspace.slug,
+ { ...currentDocumentData, pageContent: newContent, docId: document.docId },
+ document.docpath,
+ true
+ );
+ updateSourceDocument(
+ document.docpath,
+ {
+ ...currentDocumentData,
+ pageContent: newContent,
+ docId: document.docId,
+ published: (new Date).toLocaleString(),
+ // Todo: Update word count and token_estimate?
+ }
+ )
+ log(`Workspace "${workspace.name}" vectors of ${source} updated. Document and vector cache updated.`)
+
+
+ // Now we can bloom the results to all matching documents in all other workspaces
+ const workspacesModified = [workspace.slug];
+ const moreReferences = await Document.where({
+ id: { not: document.id },
+ filename: document.filename
+ }, null, null, { workspace: true });
+
+ if (moreReferences.length !== 0) {
+ log(`${source} is referenced in ${moreReferences.length} other workspaces. Updating those workspaces as well...`)
+ for (const additionalDocumentRef of moreReferences) {
+ const additionalWorkspace = additionalDocumentRef.workspace;
+ workspacesModified.push(additionalWorkspace.slug);
+
+ await vectorDatabase.deleteDocumentFromNamespace(additionalWorkspace.slug, additionalDocumentRef.docId);
+ await vectorDatabase.addDocumentToNamespace(
+ additionalWorkspace.slug,
+ { ...currentDocumentData, pageContent: newContent, docId: additionalDocumentRef.docId },
+ additionalDocumentRef.docpath,
+ );
+ log(`Workspace "${additionalWorkspace.name}" vectors for ${source} was also updated with the new content from cache.`)
+ }
+ }
+
+ const nextRefresh = DocumentSyncQueue.calcNextSync(queue);
+ log(`${source} has been refreshed in all workspaces it is currently referenced in. Next refresh will be ${nextRefresh.toLocaleString()}.`)
+ await DocumentSyncQueue._update(
+ queue.id,
+ {
+ lastSyncedAt: new Date().toISOString(),
+ nextSyncAt: nextRefresh.toISOString(),
+ }
+ );
+ await DocumentSyncQueue.saveRun(queue.id, DocumentSyncRun.statuses.success, { filename: document.filename, workspacesModified })
+ }
+ } catch (e) {
+ console.error(e)
+ log(`errored with ${e.message}`)
+ } finally {
+ conclude();
+ }
+})();
diff --git a/server/jsconfig.json b/server/jsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..e3ab1d2716ec77c86820b91fb2b826431ce9d469
--- /dev/null
+++ b/server/jsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "ES2020"
+ },
+ "include": [
+ "./endpoints/**/*",
+ "./models/**/*",
+ "./utils/**/*",
+ "./swagger/**/*",
+ "index.js"
+ ],
+ "exclude": ["node_modules", "storage"]
+}
diff --git a/server/models/apiKeys.js b/server/models/apiKeys.js
new file mode 100644
index 0000000000000000000000000000000000000000..32727fec8d223b6b78e4d144acee95e0afbf0f41
--- /dev/null
+++ b/server/models/apiKeys.js
@@ -0,0 +1,96 @@
+const prisma = require("../utils/prisma");
+
+const ApiKey = {
+ tablename: "api_keys",
+ writable: [],
+
+ makeSecret: () => {
+ const uuidAPIKey = require("uuid-apikey");
+ return uuidAPIKey.create().apiKey;
+ },
+
+ create: async function (createdByUserId = null) {
+ try {
+ const apiKey = await prisma.api_keys.create({
+ data: {
+ secret: this.makeSecret(),
+ createdBy: createdByUserId,
+ },
+ });
+
+ return { apiKey, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE API KEY.", error.message);
+ return { apiKey: null, error: error.message };
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const apiKey = await prisma.api_keys.findFirst({ where: clause });
+ return apiKey;
+ } catch (error) {
+ console.error("FAILED TO GET API KEY.", error.message);
+ return null;
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.api_keys.count({ where: clause });
+ return count;
+ } catch (error) {
+ console.error("FAILED TO COUNT API KEYS.", error.message);
+ return 0;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.api_keys.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error("FAILED TO DELETE API KEY.", error.message);
+ return false;
+ }
+ },
+
+ where: async function (clause = {}, limit) {
+ try {
+ const apiKeys = await prisma.api_keys.findMany({
+ where: clause,
+ take: limit,
+ });
+ return apiKeys;
+ } catch (error) {
+ console.error("FAILED TO GET API KEYS.", error.message);
+ return [];
+ }
+ },
+
+ whereWithUser: async function (clause = {}, limit) {
+ try {
+ const { User } = require("./user");
+ const apiKeys = await this.where(clause, limit);
+
+ for (const apiKey of apiKeys) {
+ if (!apiKey.createdBy) continue;
+ const user = await User.get({ id: apiKey.createdBy });
+ if (!user) continue;
+
+ apiKey.createdBy = {
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ };
+ }
+
+ return apiKeys;
+ } catch (error) {
+ console.error("FAILED TO GET API KEYS WITH USER.", error.message);
+ return [];
+ }
+ },
+};
+
+module.exports = { ApiKey };
diff --git a/server/models/browserExtensionApiKey.js b/server/models/browserExtensionApiKey.js
new file mode 100644
index 0000000000000000000000000000000000000000..45759d98d126b44eb484d24dfc3b88a74673aadf
--- /dev/null
+++ b/server/models/browserExtensionApiKey.js
@@ -0,0 +1,168 @@
+const prisma = require("../utils/prisma");
+const { SystemSettings } = require("./systemSettings");
+const { ROLES } = require("../utils/middleware/multiUserProtected");
+
+const BrowserExtensionApiKey = {
+ /**
+ * Creates a new secret for a browser extension API key.
+ * @returns {string} brx-*** API key to use with extension
+ */
+ makeSecret: () => {
+ const uuidAPIKey = require("uuid-apikey");
+ return `brx-${uuidAPIKey.create().apiKey}`;
+ },
+
+ /**
+ * Creates a new api key for the browser Extension
+ * @param {number|null} userId - User id to associate creation of key with.
+ * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|null, error:string|null}>}
+ */
+ create: async function (userId = null) {
+ try {
+ const apiKey = await prisma.browser_extension_api_keys.create({
+ data: {
+ key: this.makeSecret(),
+ user_id: userId,
+ },
+ });
+ return { apiKey, error: null };
+ } catch (error) {
+ console.error("Failed to create browser extension API key", error);
+ return { apiKey: null, error: error.message };
+ }
+ },
+
+ /**
+ * Validated existing API key
+ * @param {string} key
+ * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>}
+ */
+ validate: async function (key) {
+ if (!key.startsWith("brx-")) return false;
+ const apiKey = await prisma.browser_extension_api_keys.findUnique({
+ where: { key: key.toString() },
+ include: { user: true },
+ });
+ if (!apiKey) return false;
+
+ const multiUserMode = await SystemSettings.isMultiUserMode();
+ if (!multiUserMode) return apiKey; // In single-user mode, all keys are valid
+
+ // In multi-user mode, check if the key is associated with a user
+ return apiKey.user_id ? apiKey : false;
+ },
+
+ /**
+ * Fetches browser api key by params.
+ * @param {object} clause - Prisma props for search
+ * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>}
+ */
+ get: async function (clause = {}) {
+ try {
+ const apiKey = await prisma.browser_extension_api_keys.findFirst({
+ where: clause,
+ });
+ return apiKey;
+ } catch (error) {
+ console.error("FAILED TO GET BROWSER EXTENSION API KEY.", error.message);
+ return null;
+ }
+ },
+
+ /**
+ * Deletes browser api key by db id.
+ * @param {number} id - database id of browser key
+ * @returns {Promise<{success: boolean, error:string|null}>}
+ */
+ delete: async function (id) {
+ try {
+ await prisma.browser_extension_api_keys.delete({
+ where: { id: parseInt(id) },
+ });
+ return { success: true, error: null };
+ } catch (error) {
+ console.error("Failed to delete browser extension API key", error);
+ return { success: false, error: error.message };
+ }
+ },
+
+ /**
+ * Gets browser keys by params
+ * @param {object} clause
+ * @param {number|null} limit
+ * @param {object|null} orderBy
+ * @returns {Promise}
+ */
+ where: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const apiKeys = await prisma.browser_extension_api_keys.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ include: { user: true },
+ });
+ return apiKeys;
+ } catch (error) {
+ console.error("FAILED TO GET BROWSER EXTENSION API KEYS.", error.message);
+ return [];
+ }
+ },
+
+ /**
+ * Get browser API keys for user
+ * @param {import("@prisma/client").users} user
+ * @param {object} clause
+ * @param {number|null} limit
+ * @param {object|null} orderBy
+ * @returns {Promise}
+ */
+ whereWithUser: async function (
+ user,
+ clause = {},
+ limit = null,
+ orderBy = null
+ ) {
+ // Admin can view and use any keys
+ if ([ROLES.admin].includes(user.role))
+ return await this.where(clause, limit, orderBy);
+
+ try {
+ const apiKeys = await prisma.browser_extension_api_keys.findMany({
+ where: {
+ ...clause,
+ user_id: user.id,
+ },
+ include: { user: true },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return apiKeys;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ /**
+ * Updates owner of all DB ids to new admin.
+ * @param {number} userId
+ * @returns {Promise}
+ */
+ migrateApiKeysToMultiUser: async function (userId) {
+ try {
+ await prisma.browser_extension_api_keys.updateMany({
+ where: {
+ user_id: null,
+ },
+ data: {
+ user_id: userId,
+ },
+ });
+ console.log("Successfully migrated API keys to multi-user mode");
+ } catch (error) {
+ console.error("Error migrating API keys to multi-user mode:", error);
+ }
+ },
+};
+
+module.exports = { BrowserExtensionApiKey };
diff --git a/server/models/cacheData.js b/server/models/cacheData.js
new file mode 100644
index 0000000000000000000000000000000000000000..43c281d553d8eb76f499c824c3834a2cbf69f19c
--- /dev/null
+++ b/server/models/cacheData.js
@@ -0,0 +1,69 @@
+const prisma = require("../utils/prisma");
+
+const CacheData = {
+ new: async function (inputs = {}) {
+ try {
+ const cache = await prisma.cache_data.create({
+ data: inputs,
+ });
+ return { cache, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { cache: null, message: error.message };
+ }
+ },
+
+ get: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const cache = await prisma.cache_data.findFirst({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return cache || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.cache_data.deleteMany({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const caches = await prisma.cache_data.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return caches;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.cache_data.count({
+ where: clause,
+ });
+ return count;
+ } catch (error) {
+ console.error(error.message);
+ return 0;
+ }
+ },
+};
+
+module.exports = { CacheData };
diff --git a/server/models/communityHub.js b/server/models/communityHub.js
new file mode 100644
index 0000000000000000000000000000000000000000..e9e669525153a3edc4af3bfa71f3fe4696dfcc13
--- /dev/null
+++ b/server/models/communityHub.js
@@ -0,0 +1,213 @@
+const ImportedPlugin = require("../utils/agents/imported");
+
+/**
+ * An interface to the AnythingLLM Community Hub external API.
+ */
+const CommunityHub = {
+ importPrefix: "allm-community-id",
+ apiBase:
+ process.env.NODE_ENV === "development"
+ ? "http://127.0.0.1:5001/anythingllm-hub/us-central1/external/v1"
+ : "https://hub.external.anythingllm.com/v1",
+ supportedStaticItemTypes: ["system-prompt", "agent-flow", "slash-command"],
+
+ /**
+ * Validate an import ID and return the entity type and ID.
+ * @param {string} importId - The import ID to validate.
+ * @returns {{entityType: string | null, entityId: string | null}}
+ */
+ validateImportId: function (importId) {
+ if (
+ !importId ||
+ !importId.startsWith(this.importPrefix) ||
+ importId.split(":").length !== 3
+ )
+ return { entityType: null, entityId: null };
+ const [_, entityType, entityId] = importId.split(":");
+ if (!entityType || !entityId) return { entityType: null, entityId: null };
+ return {
+ entityType: String(entityType).trim(),
+ entityId: String(entityId).trim(),
+ };
+ },
+
+ /**
+ * Fetch the explore items from the community hub that are publicly available.
+ * @returns {Promise<{agentSkills: {items: [], hasMore: boolean, totalCount: number}, systemPrompts: {items: [], hasMore: boolean, totalCount: number}, slashCommands: {items: [], hasMore: boolean, totalCount: number}}>}
+ */
+ fetchExploreItems: async function () {
+ return await fetch(`${this.apiBase}/explore`, {
+ method: "GET",
+ })
+ .then((response) => response.json())
+ .catch((error) => {
+ console.error("Error fetching explore items:", error);
+ return {
+ agentSkills: {
+ items: [],
+ hasMore: false,
+ totalCount: 0,
+ },
+ systemPrompts: {
+ items: [],
+ hasMore: false,
+ totalCount: 0,
+ },
+ slashCommands: {
+ items: [],
+ hasMore: false,
+ totalCount: 0,
+ },
+ };
+ });
+ },
+
+ /**
+ * Fetch a bundle item from the community hub.
+ * Bundle items are entities that require a downloadURL to be fetched from the community hub.
+ * so we can unzip and import them to the AnythingLLM instance.
+ * @param {string} importId - The import ID of the item.
+ * @returns {Promise<{url: string | null, item: object | null, error: string | null}>}
+ */
+ getBundleItem: async function (importId) {
+ const { entityType, entityId } = this.validateImportId(importId);
+ if (!entityType || !entityId)
+ return { item: null, error: "Invalid import ID" };
+
+ const { SystemSettings } = require("./systemSettings");
+ const { connectionKey } = await SystemSettings.hubSettings();
+ const { url, item, error } = await fetch(
+ `${this.apiBase}/${entityType}/${entityId}/pull`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ ...(connectionKey
+ ? { Authorization: `Bearer ${connectionKey}` }
+ : {}),
+ },
+ }
+ )
+ .then((response) => response.json())
+ .catch((error) => {
+ console.error(
+ `Error fetching bundle item for import ID ${importId}:`,
+ error
+ );
+ return { url: null, item: null, error: error.message };
+ });
+ return { url, item, error };
+ },
+
+ /**
+ * Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts.
+ * @param {object} item - The item to apply.
+ * @param {object} options - Additional options for applying the item.
+ * @param {object|null} options.currentUser - The current user object.
+ * @returns {Promise<{success: boolean, error: string | null}>}
+ */
+ applyItem: async function (item, options = {}) {
+ if (!item) return { success: false, error: "Item is required" };
+
+ if (item.itemType === "system-prompt") {
+ if (!options?.workspaceSlug)
+ return { success: false, error: "Workspace slug is required" };
+
+ const { Workspace } = require("./workspace");
+ const workspace = await Workspace.get({
+ slug: String(options.workspaceSlug),
+ });
+ if (!workspace) return { success: false, error: "Workspace not found" };
+ await Workspace.update(workspace.id, { openAiPrompt: item.prompt });
+ return { success: true, error: null };
+ }
+
+ if (item.itemType === "slash-command") {
+ const { SlashCommandPresets } = require("./slashCommandsPresets");
+ await SlashCommandPresets.create(options?.currentUser?.id, {
+ command: SlashCommandPresets.formatCommand(String(item.command)),
+ prompt: String(item.prompt),
+ description: String(item.description),
+ });
+ return { success: true, error: null };
+ }
+
+ return {
+ success: false,
+ error: "Unsupported item type. Nothing to apply.",
+ };
+ },
+
+ /**
+ * Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it.
+ * or whatever the item type requires.
+ * @param {{url: string, item: object}} params
+ * @returns {Promise<{success: boolean, error: string | null}>}
+ */
+ importBundleItem: async function ({ url, item }) {
+ if (item.itemType === "agent-skill") {
+ const { success, error } =
+ await ImportedPlugin.importCommunityItemFromUrl(url, item);
+ return { success, error };
+ }
+
+ return {
+ success: false,
+ error: "Unsupported item type. Nothing to import.",
+ };
+ },
+
+ fetchUserItems: async function (connectionKey) {
+ if (!connectionKey) return { createdByMe: {}, teamItems: [] };
+
+ return await fetch(`${this.apiBase}/items`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${connectionKey}`,
+ },
+ })
+ .then((response) => response.json())
+ .catch((error) => {
+ console.error("Error fetching user items:", error);
+ return { createdByMe: {}, teamItems: [] };
+ });
+ },
+
+ /**
+ * Create a new item in the community hub - Only supports STATIC items for now.
+ * @param {string} itemType - The type of item to create
+ * @param {object} data - The item data
+ * @param {string} connectionKey - The hub connection key
+ * @returns {Promise<{success: boolean, error: string | null}>}
+ */
+ createStaticItem: async function (itemType, data, connectionKey) {
+ if (!connectionKey)
+ return { success: false, error: "Connection key is required" };
+ if (!this.supportedStaticItemTypes.includes(itemType))
+ return { success: false, error: "Unsupported item type" };
+
+ // If the item has special considerations or preprocessing, we can delegate that below before sending the request.
+ // eg: Agent flow files and such.
+
+ return await fetch(`${this.apiBase}/${itemType}/create`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${connectionKey}`,
+ },
+ body: JSON.stringify(data),
+ })
+ .then((response) => response.json())
+ .then((result) => {
+ if (!!result.error) throw new Error(result.error || "Unknown error");
+ return { success: true, error: null, itemId: result.item.id };
+ })
+ .catch((error) => {
+ console.error(`Error creating ${itemType}:`, error);
+ return { success: false, error: error.message };
+ });
+ },
+};
+
+module.exports = { CommunityHub };
diff --git a/server/models/documentSyncQueue.js b/server/models/documentSyncQueue.js
new file mode 100644
index 0000000000000000000000000000000000000000..b4e9790ce1caaa80192dab540b667d1ce8e1c539
--- /dev/null
+++ b/server/models/documentSyncQueue.js
@@ -0,0 +1,255 @@
+const { BackgroundService } = require("../utils/BackgroundWorkers");
+const prisma = require("../utils/prisma");
+const { SystemSettings } = require("./systemSettings");
+const { Telemetry } = require("./telemetry");
+
+/**
+ * @typedef {('link'|'youtube'|'confluence'|'github'|'gitlab')} validFileType
+ */
+
+const DocumentSyncQueue = {
+ featureKey: "experimental_live_file_sync",
+ // update the validFileTypes and .canWatch properties when adding elements here.
+ validFileTypes: [
+ "link",
+ "youtube",
+ "confluence",
+ "github",
+ "gitlab",
+ "drupalwiki",
+ ],
+ defaultStaleAfter: 604800000,
+ maxRepeatFailures: 5, // How many times a run can fail in a row before pruning.
+ writable: [],
+
+ bootWorkers: function () {
+ new BackgroundService().boot();
+ },
+
+ killWorkers: function () {
+ new BackgroundService().stop();
+ },
+
+ /** Check is the Document Sync/Watch feature is enabled and can be used. */
+ enabled: async function () {
+ return (
+ (await SystemSettings.get({ label: this.featureKey }))?.value ===
+ "enabled"
+ );
+ },
+
+ /**
+ * @param {import("@prisma/client").document_sync_queues} queueRecord - queue record to calculate for
+ */
+ calcNextSync: function (queueRecord) {
+ return new Date(Number(new Date()) + queueRecord.staleAfterMs);
+ },
+
+ /**
+ * Check if the document can be watched based on the metadata fields
+ * @param {object} metadata - metadata to check
+ * @param {string} metadata.title - title of the document
+ * @param {string} metadata.chunkSource - chunk source of the document
+ * @returns {boolean} - true if the document can be watched, false otherwise
+ */
+ canWatch: function ({ title, chunkSource = null } = {}) {
+ if (!chunkSource) return false;
+
+ if (chunkSource.startsWith("link://") && title.endsWith(".html"))
+ return true; // If is web-link material (prior to feature most chunkSources were links://)
+ if (chunkSource.startsWith("youtube://")) return true; // If is a youtube link
+ if (chunkSource.startsWith("confluence://")) return true; // If is a confluence document link
+ if (chunkSource.startsWith("github://")) return true; // If is a GitHub file reference
+ if (chunkSource.startsWith("gitlab://")) return true; // If is a GitLab file reference
+ if (chunkSource.startsWith("drupalwiki://")) return true; // If is a DrupalWiki document link
+ return false;
+ },
+
+ /**
+ * Creates Queue record and updates document watch status to true on Document record
+ * @param {import("@prisma/client").workspace_documents} document - document record to watch, must have `id`
+ */
+ watch: async function (document = null) {
+ if (!document) return false;
+ try {
+ const { Document } = require("./documents");
+
+ // Get all documents that are watched and share the same unique filename. If this value is
+ // non-zero then we exit early so that we do not have duplicated watch queues for the same file
+ // across many workspaces.
+ const workspaceDocIds = (
+ await Document.where({ filename: document.filename, watched: true })
+ ).map((rec) => rec.id);
+ const hasRecords =
+ (await this.count({ workspaceDocId: { in: workspaceDocIds } })) > 0;
+ if (hasRecords)
+ throw new Error(
+ `Cannot watch this document again - it already has a queue set.`
+ );
+
+ const queue = await prisma.document_sync_queues.create({
+ data: {
+ workspaceDocId: document.id,
+ nextSyncAt: new Date(Number(new Date()) + this.defaultStaleAfter),
+ },
+ });
+ await Document._updateAll(
+ { filename: document.filename },
+ { watched: true }
+ );
+ return queue || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ /**
+ * Deletes Queue record and updates document watch status to false on Document record
+ * @param {import("@prisma/client").workspace_documents} document - document record to unwatch, must have `id`
+ */
+ unwatch: async function (document = null) {
+ if (!document) return false;
+ try {
+ const { Document } = require("./documents");
+
+ // We could have been given a document to unwatch which is a clone of one that is already being watched but by another workspaceDocument id.
+ // so in this instance we need to delete any queues related to this document by any WorkspaceDocumentId it is referenced by.
+ const workspaceDocIds = (
+ await Document.where({ filename: document.filename, watched: true })
+ ).map((rec) => rec.id);
+ await this.delete({ workspaceDocId: { in: workspaceDocIds } });
+ await Document._updateAll(
+ { filename: document.filename },
+ { watched: false }
+ );
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ _update: async function (id = null, data = {}) {
+ if (!id) throw new Error("No id provided for update");
+
+ try {
+ await prisma.document_sync_queues.update({
+ where: { id },
+ data,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const queue = await prisma.document_sync_queues.findFirst({
+ where: clause,
+ });
+ return queue || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ include = {}
+ ) {
+ try {
+ const results = await prisma.document_sync_queues.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ ...(include !== null ? { include } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ count: async function (clause = {}, limit = null) {
+ try {
+ const count = await prisma.document_sync_queues.count({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ });
+ return count;
+ } catch (error) {
+ console.error("FAILED TO COUNT DOCUMENTS.", error.message);
+ return 0;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.document_sync_queues.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ /**
+ * Gets the "stale" queues where the queue's nextSyncAt is less than the current time
+ * @returns {Promise<(
+ * import("@prisma/client").document_sync_queues &
+ * { workspaceDoc: import("@prisma/client").workspace_documents &
+ * { workspace: import("@prisma/client").workspaces }
+ * })[]}>}
+ */
+ staleDocumentQueues: async function () {
+ const queues = await this.where(
+ {
+ nextSyncAt: {
+ lte: new Date().toISOString(),
+ },
+ },
+ null,
+ null,
+ {
+ workspaceDoc: {
+ include: {
+ workspace: true,
+ },
+ },
+ }
+ );
+ return queues;
+ },
+
+ saveRun: async function (queueId = null, status = null, result = {}) {
+ const { DocumentSyncRun } = require("./documentSyncRun");
+ return DocumentSyncRun.save(queueId, status, result);
+ },
+
+ /**
+ * Updates document to be watched/unwatched & creates or deletes any queue records and updated Document record `watched` status
+ * @param {import("@prisma/client").workspace_documents} documentRecord
+ * @param {boolean} watchStatus - indicate if queue record should be created or not.
+ * @returns
+ */
+ toggleWatchStatus: async function (documentRecord, watchStatus = false) {
+ if (!watchStatus) {
+ await Telemetry.sendTelemetry("document_unwatched");
+ await this.unwatch(documentRecord);
+ return;
+ }
+
+ await this.watch(documentRecord);
+ await Telemetry.sendTelemetry("document_watched");
+ return;
+ },
+};
+
+module.exports = { DocumentSyncQueue };
diff --git a/server/models/documentSyncRun.js b/server/models/documentSyncRun.js
new file mode 100644
index 0000000000000000000000000000000000000000..94fcf3ff56216d87ef668d8b45dcd885bf31a3e0
--- /dev/null
+++ b/server/models/documentSyncRun.js
@@ -0,0 +1,88 @@
+const prisma = require("../utils/prisma");
+const DocumentSyncRun = {
+ statuses: {
+ unknown: "unknown",
+ exited: "exited",
+ failed: "failed",
+ success: "success",
+ },
+
+ save: async function (queueId = null, status = null, result = {}) {
+ try {
+ if (!this.statuses.hasOwnProperty(status))
+ throw new Error(
+ `DocumentSyncRun status ${status} is not a valid status.`
+ );
+
+ const run = await prisma.document_sync_executions.create({
+ data: {
+ queueId: Number(queueId),
+ status: String(status),
+ result: JSON.stringify(result),
+ },
+ });
+ return run || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const queue = await prisma.document_sync_executions.findFirst({
+ where: clause,
+ });
+ return queue || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ include = {}
+ ) {
+ try {
+ const results = await prisma.document_sync_executions.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ ...(include !== null ? { include } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ count: async function (clause = {}, limit = null, orderBy = {}) {
+ try {
+ const count = await prisma.document_sync_executions.count({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return count;
+ } catch (error) {
+ console.error("FAILED TO COUNT DOCUMENTS.", error.message);
+ return 0;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.document_sync_executions.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+};
+
+module.exports = { DocumentSyncRun };
diff --git a/server/models/documents.js b/server/models/documents.js
new file mode 100644
index 0000000000000000000000000000000000000000..a283311537e36869d37ae9f86aab68ffcfc98f43
--- /dev/null
+++ b/server/models/documents.js
@@ -0,0 +1,307 @@
+const { v4: uuidv4 } = require("uuid");
+const { getVectorDbClass } = require("../utils/helpers");
+const prisma = require("../utils/prisma");
+const { Telemetry } = require("./telemetry");
+const { EventLogs } = require("./eventLogs");
+const { safeJsonParse } = require("../utils/http");
+const { getModelTag } = require("../endpoints/utils");
+
+const Document = {
+ writable: ["pinned", "watched", "lastUpdatedAt"],
+ /**
+ * @param {import("@prisma/client").workspace_documents} document - Document PrismaRecord
+ * @returns {{
+ * metadata: (null|object),
+ * type: import("./documentSyncQueue.js").validFileType,
+ * source: string
+ * }}
+ */
+ parseDocumentTypeAndSource: function (document) {
+ const metadata = safeJsonParse(document.metadata, null);
+ if (!metadata) return { metadata: null, type: null, source: null };
+
+ // Parse the correct type of source and its original source path.
+ const idx = metadata.chunkSource.indexOf("://");
+ const [type, source] = [
+ metadata.chunkSource.slice(0, idx),
+ metadata.chunkSource.slice(idx + 3),
+ ];
+ return { metadata, type, source: this._stripSource(source, type) };
+ },
+
+ forWorkspace: async function (workspaceId = null) {
+ if (!workspaceId) return [];
+ return await prisma.workspace_documents.findMany({
+ where: { workspaceId },
+ });
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.workspace_documents.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const document = await prisma.workspace_documents.findFirst({
+ where: clause,
+ });
+ return document || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ include = null,
+ select = null
+ ) {
+ try {
+ const results = await prisma.workspace_documents.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ ...(include !== null ? { include } : {}),
+ ...(select !== null ? { select: { ...select } } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ addDocuments: async function (workspace, additions = [], userId = null) {
+ const VectorDb = getVectorDbClass();
+ if (additions.length === 0) return { failed: [], embedded: [] };
+ const { fileData } = require("../utils/files");
+ const embedded = [];
+ const failedToEmbed = [];
+ const errors = new Set();
+
+ for (const path of additions) {
+ const data = await fileData(path);
+ if (!data) continue;
+
+ const docId = uuidv4();
+ const { pageContent, ...metadata } = data;
+ const newDoc = {
+ docId,
+ filename: path.split("/")[1],
+ docpath: path,
+ workspaceId: workspace.id,
+ metadata: JSON.stringify(metadata),
+ };
+
+ const { vectorized, error } = await VectorDb.addDocumentToNamespace(
+ workspace.slug,
+ { ...data, docId },
+ path
+ );
+
+ if (!vectorized) {
+ console.error(
+ "Failed to vectorize",
+ metadata?.title || newDoc.filename
+ );
+ failedToEmbed.push(metadata?.title || newDoc.filename);
+ errors.add(error);
+ continue;
+ }
+
+ try {
+ await prisma.workspace_documents.create({ data: newDoc });
+ embedded.push(path);
+ } catch (error) {
+ console.error(error.message);
+ }
+ }
+
+ await Telemetry.sendTelemetry("documents_embedded_in_workspace", {
+ LLMSelection: process.env.LLM_PROVIDER || "openai",
+ Embedder: process.env.EMBEDDING_ENGINE || "inherit",
+ VectorDbSelection: process.env.VECTOR_DB || "lancedb",
+ TTSSelection: process.env.TTS_PROVIDER || "native",
+ LLMModel: getModelTag(),
+ });
+ await EventLogs.logEvent(
+ "workspace_documents_added",
+ {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ numberOfDocumentsAdded: additions.length,
+ },
+ userId
+ );
+ return { failedToEmbed, errors: Array.from(errors), embedded };
+ },
+
+ removeDocuments: async function (workspace, removals = [], userId = null) {
+ const VectorDb = getVectorDbClass();
+ if (removals.length === 0) return;
+
+ for (const path of removals) {
+ const document = await this.get({
+ docpath: path,
+ workspaceId: workspace.id,
+ });
+ if (!document) continue;
+ await VectorDb.deleteDocumentFromNamespace(
+ workspace.slug,
+ document.docId
+ );
+
+ try {
+ await prisma.workspace_documents.delete({
+ where: { id: document.id, workspaceId: workspace.id },
+ });
+ await prisma.document_vectors.deleteMany({
+ where: { docId: document.docId },
+ });
+ } catch (error) {
+ console.error(error.message);
+ }
+ }
+
+ await EventLogs.logEvent(
+ "workspace_documents_removed",
+ {
+ workspaceName: workspace?.name || "Unknown Workspace",
+ numberOfDocuments: removals.length,
+ },
+ userId
+ );
+ return true;
+ },
+
+ count: async function (clause = {}, limit = null) {
+ try {
+ const count = await prisma.workspace_documents.count({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ });
+ return count;
+ } catch (error) {
+ console.error("FAILED TO COUNT DOCUMENTS.", error.message);
+ return 0;
+ }
+ },
+ update: async function (id = null, data = {}) {
+ if (!id) throw new Error("No workspace document id provided for update");
+
+ const validKeys = Object.keys(data).filter((key) =>
+ this.writable.includes(key)
+ );
+ if (validKeys.length === 0)
+ return { document: { id }, message: "No valid fields to update!" };
+
+ try {
+ const document = await prisma.workspace_documents.update({
+ where: { id },
+ data,
+ });
+ return { document, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { document: null, message: error.message };
+ }
+ },
+ _updateAll: async function (clause = {}, data = {}) {
+ try {
+ await prisma.workspace_documents.updateMany({
+ where: clause,
+ data,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+ content: async function (docId) {
+ if (!docId) throw new Error("No workspace docId provided!");
+ const document = await this.get({ docId: String(docId) });
+ if (!document) throw new Error(`Could not find a document by id ${docId}`);
+
+ const { fileData } = require("../utils/files");
+ const data = await fileData(document.docpath);
+ return { title: data.title, content: data.pageContent };
+ },
+ contentByDocPath: async function (docPath) {
+ const { fileData } = require("../utils/files");
+ const data = await fileData(docPath);
+ return { title: data.title, content: data.pageContent };
+ },
+
+ // Some data sources have encoded params in them we don't want to log - so strip those details.
+ _stripSource: function (sourceString, type) {
+ if (["confluence", "github"].includes(type)) {
+ const _src = new URL(sourceString);
+ _src.search = ""; // remove all search params that are encoded for resync.
+ return _src.toString();
+ }
+
+ return sourceString;
+ },
+
+ /**
+ * Functions for the backend API endpoints - not to be used by the frontend or elsewhere.
+ * @namespace api
+ */
+ api: {
+ /**
+ * Process a document upload from the API and upsert it into the database. This
+ * functionality should only be used by the backend /v1/documents/upload endpoints for post-upload embedding.
+ * @param {string} wsSlugs - The slugs of the workspaces to embed the document into, will be comma-separated list of workspace slugs
+ * @param {string} docLocation - The location/path of the document that was uploaded
+ * @returns {Promise} - True if the document was uploaded successfully, false otherwise
+ */
+ uploadToWorkspace: async function (wsSlugs = "", docLocation = null) {
+ if (!docLocation)
+ return console.log(
+ "No document location provided for embedding",
+ docLocation
+ );
+
+ const slugs = wsSlugs
+ .split(",")
+ .map((slug) => String(slug)?.trim()?.toLowerCase());
+ if (slugs.length === 0)
+ return console.log(`No workspaces provided got: ${wsSlugs}`);
+
+ const { Workspace } = require("./workspace");
+ const workspaces = await Workspace.where({ slug: { in: slugs } });
+ if (workspaces.length === 0)
+ return console.log("No valid workspaces found for slugs: ", slugs);
+
+ // Upsert the document into each workspace - do this sequentially
+ // because the document may be large and we don't want to overwhelm the embedder, plus on the first
+ // upsert we will then have the cache of the document - making n+1 embeds faster. If we parallelize this
+ // we will have to do a lot of extra work to ensure that the document is not embedded more than once.
+ for (const workspace of workspaces) {
+ const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
+ workspace,
+ [docLocation]
+ );
+ if (failedToEmbed.length > 0)
+ return console.log(
+ `Failed to embed document into workspace ${workspace.slug}`,
+ errors
+ );
+ console.log(`Document embedded into workspace ${workspace.slug}...`);
+ }
+
+ return true;
+ },
+ },
+};
+
+module.exports = { Document };
diff --git a/server/models/embedChats.js b/server/models/embedChats.js
new file mode 100644
index 0000000000000000000000000000000000000000..9f11b1c6e56e1fbcd1d335b329cfc3189b00d119
--- /dev/null
+++ b/server/models/embedChats.js
@@ -0,0 +1,199 @@
+const { safeJsonParse } = require("../utils/http");
+const prisma = require("../utils/prisma");
+
+/**
+ * @typedef {Object} EmbedChat
+ * @property {number} id
+ * @property {number} embed_id
+ * @property {string} prompt
+ * @property {string} response
+ * @property {string} connection_information
+ * @property {string} session_id
+ * @property {boolean} include
+ */
+
+const EmbedChats = {
+ new: async function ({
+ embedId,
+ prompt,
+ response = {},
+ connection_information = {},
+ sessionId,
+ }) {
+ try {
+ const chat = await prisma.embed_chats.create({
+ data: {
+ prompt,
+ embed_id: Number(embedId),
+ response: JSON.stringify(response),
+ connection_information: JSON.stringify(connection_information),
+ session_id: String(sessionId),
+ },
+ });
+ return { chat, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { chat: null, message: error.message };
+ }
+ },
+
+ /**
+ * Loops through each chat and filters out the sources from the response object.
+ * We do this when returning /history of an embed to the frontend to prevent inadvertent leaking
+ * of private sources the user may not have intended to share with users.
+ * @param {EmbedChat[]} chats
+ * @returns {EmbedChat[]} Returns a new array of chats with the sources filtered out of responses
+ */
+ filterSources: function (chats) {
+ return chats.map((chat) => {
+ const { response, ...rest } = chat;
+ const { sources, ...responseRest } = safeJsonParse(response);
+ return { ...rest, response: JSON.stringify(responseRest) };
+ });
+ },
+
+ /**
+ * Fetches chats for a given embed and session id.
+ * @param {number} embedId the id of the embed to fetch chats for
+ * @param {string} sessionId the id of the session to fetch chats for
+ * @param {number|null} limit the maximum number of chats to fetch
+ * @param {string|null} orderBy the order to fetch chats in
+ * @param {boolean} filterSources whether to filter out the sources from the response (default: false)
+ * @returns {Promise} Returns an array of chats for the given embed and session
+ */
+ forEmbedByUser: async function (
+ embedId = null,
+ sessionId = null,
+ limit = null,
+ orderBy = null,
+ filterSources = false
+ ) {
+ if (!embedId || !sessionId) return [];
+
+ try {
+ const chats = await prisma.embed_chats.findMany({
+ where: {
+ embed_id: Number(embedId),
+ session_id: String(sessionId),
+ include: true,
+ },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }),
+ });
+ return filterSources ? this.filterSources(chats) : chats;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ markHistoryInvalid: async function (embedId = null, sessionId = null) {
+ if (!embedId || !sessionId) return [];
+
+ try {
+ await prisma.embed_chats.updateMany({
+ where: {
+ embed_id: Number(embedId),
+ session_id: String(sessionId),
+ },
+ data: {
+ include: false,
+ },
+ });
+ return;
+ } catch (error) {
+ console.error(error.message);
+ }
+ },
+
+ get: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const chat = await prisma.embed_chats.findFirst({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return chat || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.embed_chats.deleteMany({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ offset = null
+ ) {
+ try {
+ const chats = await prisma.embed_chats.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(offset !== null ? { skip: offset } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return chats;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ whereWithEmbedAndWorkspace: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ offset = null
+ ) {
+ try {
+ const chats = await prisma.embed_chats.findMany({
+ where: clause,
+ include: {
+ embed_config: {
+ select: {
+ workspace: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ },
+ },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(offset !== null ? { skip: offset } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return chats;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.embed_chats.count({
+ where: clause,
+ });
+ return count;
+ } catch (error) {
+ console.error(error.message);
+ return 0;
+ }
+ },
+};
+
+module.exports = { EmbedChats };
diff --git a/server/models/embedConfig.js b/server/models/embedConfig.js
new file mode 100644
index 0000000000000000000000000000000000000000..202c5a68fbd140921098271c5e7415e09d30e140
--- /dev/null
+++ b/server/models/embedConfig.js
@@ -0,0 +1,245 @@
+const { v4 } = require("uuid");
+const prisma = require("../utils/prisma");
+const { VALID_CHAT_MODE } = require("../utils/chats/stream");
+
+const EmbedConfig = {
+ writable: [
+ // Used for generic updates so we can validate keys in request body
+ "enabled",
+ "allowlist_domains",
+ "allow_model_override",
+ "allow_temperature_override",
+ "allow_prompt_override",
+ "max_chats_per_day",
+ "max_chats_per_session",
+ "chat_mode",
+ "workspace_id",
+ "message_limit",
+ ],
+
+ new: async function (data, creatorId = null) {
+ try {
+ const embed = await prisma.embed_configs.create({
+ data: {
+ uuid: v4(),
+ enabled: true,
+ chat_mode: validatedCreationData(data?.chat_mode, "chat_mode"),
+ allowlist_domains: validatedCreationData(
+ data?.allowlist_domains,
+ "allowlist_domains"
+ ),
+ allow_model_override: validatedCreationData(
+ data?.allow_model_override,
+ "allow_model_override"
+ ),
+ allow_temperature_override: validatedCreationData(
+ data?.allow_temperature_override,
+ "allow_temperature_override"
+ ),
+ allow_prompt_override: validatedCreationData(
+ data?.allow_prompt_override,
+ "allow_prompt_override"
+ ),
+ max_chats_per_day: validatedCreationData(
+ data?.max_chats_per_day,
+ "max_chats_per_day"
+ ),
+ max_chats_per_session: validatedCreationData(
+ data?.max_chats_per_session,
+ "max_chats_per_session"
+ ),
+ message_limit: validatedCreationData(
+ data?.message_limit,
+ "message_limit"
+ ),
+ createdBy: Number(creatorId) ?? null,
+ workspace: {
+ connect: { id: Number(data.workspace_id) },
+ },
+ },
+ });
+ return { embed, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { embed: null, message: error.message };
+ }
+ },
+
+ update: async function (embedId = null, data = {}) {
+ if (!embedId) throw new Error("No embed id provided for update");
+ const validKeys = Object.keys(data).filter((key) =>
+ this.writable.includes(key)
+ );
+ if (validKeys.length === 0)
+ return { embed: { id }, message: "No valid fields to update!" };
+
+ const updates = {};
+ validKeys.map((key) => {
+ updates[key] = validatedCreationData(data[key], key);
+ });
+
+ try {
+ await prisma.embed_configs.update({
+ where: { id: Number(embedId) },
+ data: updates,
+ });
+ return { success: true, error: null };
+ } catch (error) {
+ console.error(error.message);
+ return { success: false, error: error.message };
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const embedConfig = await prisma.embed_configs.findFirst({
+ where: clause,
+ });
+
+ return embedConfig || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ getWithWorkspace: async function (clause = {}) {
+ try {
+ const embedConfig = await prisma.embed_configs.findFirst({
+ where: clause,
+ include: {
+ workspace: true,
+ },
+ });
+
+ return embedConfig || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.embed_configs.delete({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const results = await prisma.embed_configs.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ whereWithWorkspace: async function (
+ clause = {},
+ limit = null,
+ orderBy = null
+ ) {
+ try {
+ const results = await prisma.embed_configs.findMany({
+ where: clause,
+ include: {
+ workspace: true,
+ _count: {
+ select: { embed_chats: true },
+ },
+ },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ // Will return null if process should be skipped
+ // an empty array means the system will check. This
+ // prevents a bad parse from allowing all requests
+ parseAllowedHosts: function (embed) {
+ if (!embed.allowlist_domains) return null;
+
+ try {
+ return JSON.parse(embed.allowlist_domains);
+ } catch {
+ console.error(`Failed to parse allowlist_domains for Embed ${embed.id}!`);
+ return [];
+ }
+ },
+};
+
+const BOOLEAN_KEYS = [
+ "allow_model_override",
+ "allow_temperature_override",
+ "allow_prompt_override",
+ "enabled",
+];
+
+const NUMBER_KEYS = [
+ "max_chats_per_day",
+ "max_chats_per_session",
+ "workspace_id",
+ "message_limit",
+];
+
+// Helper to validate a data object strictly into the proper format
+function validatedCreationData(value, field) {
+ if (field === "chat_mode") {
+ if (!value || !VALID_CHAT_MODE.includes(value)) return "query";
+ return value;
+ }
+
+ if (field === "allowlist_domains") {
+ try {
+ if (!value) return null;
+ return JSON.stringify(
+ // Iterate and force all domains to URL object
+ // and stringify the result.
+ value
+ .split(",")
+ .map((input) => {
+ let url = input;
+ if (!url.includes("http://") && !url.includes("https://"))
+ url = `https://${url}`;
+ try {
+ new URL(url);
+ return url;
+ } catch {
+ return null;
+ }
+ })
+ .filter((u) => !!u)
+ );
+ } catch {
+ return null;
+ }
+ }
+
+ if (BOOLEAN_KEYS.includes(field)) {
+ return value === true || value === false ? value : false;
+ }
+
+ if (NUMBER_KEYS.includes(field)) {
+ return isNaN(value) || Number(value) <= 0 ? null : Number(value);
+ }
+
+ return null;
+}
+
+module.exports = { EmbedConfig };
diff --git a/server/models/eventLogs.js b/server/models/eventLogs.js
new file mode 100644
index 0000000000000000000000000000000000000000..51240431a75276f0781a8ed46cd3fe8e3a996465
--- /dev/null
+++ b/server/models/eventLogs.js
@@ -0,0 +1,129 @@
+const prisma = require("../utils/prisma");
+
+const EventLogs = {
+ logEvent: async function (event, metadata = {}, userId = null) {
+ try {
+ const eventLog = await prisma.event_logs.create({
+ data: {
+ event,
+ metadata: metadata ? JSON.stringify(metadata) : null,
+ userId: userId ? Number(userId) : null,
+ occurredAt: new Date(),
+ },
+ });
+ console.log(`\x1b[32m[Event Logged]\x1b[0m - ${event}`);
+ return { eventLog, message: null };
+ } catch (error) {
+ console.error(
+ `\x1b[31m[Event Logging Failed]\x1b[0m - ${event}`,
+ error.message
+ );
+ return { eventLog: null, message: error.message };
+ }
+ },
+
+ getByEvent: async function (event, limit = null, orderBy = null) {
+ try {
+ const logs = await prisma.event_logs.findMany({
+ where: { event },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null
+ ? { orderBy }
+ : { orderBy: { occurredAt: "desc" } }),
+ });
+ return logs;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ getByUserId: async function (userId, limit = null, orderBy = null) {
+ try {
+ const logs = await prisma.event_logs.findMany({
+ where: { userId },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null
+ ? { orderBy }
+ : { orderBy: { occurredAt: "desc" } }),
+ });
+ return logs;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ offset = null
+ ) {
+ try {
+ const logs = await prisma.event_logs.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(offset !== null ? { skip: offset } : {}),
+ ...(orderBy !== null
+ ? { orderBy }
+ : { orderBy: { occurredAt: "desc" } }),
+ });
+ return logs;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ whereWithData: async function (
+ clause = {},
+ limit = null,
+ offset = null,
+ orderBy = null
+ ) {
+ const { User } = require("./user");
+
+ try {
+ const results = await this.where(clause, limit, orderBy, offset);
+
+ for (const res of results) {
+ const user = res.userId ? await User.get({ id: res.userId }) : null;
+ res.user = user
+ ? { username: user.username }
+ : { username: "unknown user" };
+ }
+
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.event_logs.count({
+ where: clause,
+ });
+ return count;
+ } catch (error) {
+ console.error(error.message);
+ return 0;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.event_logs.deleteMany({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+};
+
+module.exports = { EventLogs };
diff --git a/server/models/invite.js b/server/models/invite.js
new file mode 100644
index 0000000000000000000000000000000000000000..781a9434fde7dd1cdd6d4df2020b2f04ef9258a5
--- /dev/null
+++ b/server/models/invite.js
@@ -0,0 +1,144 @@
+const { safeJsonParse } = require("../utils/http");
+const prisma = require("../utils/prisma");
+
+const Invite = {
+ makeCode: () => {
+ const uuidAPIKey = require("uuid-apikey");
+ return uuidAPIKey.create().apiKey;
+ },
+
+ create: async function ({ createdByUserId = 0, workspaceIds = [] }) {
+ try {
+ const invite = await prisma.invites.create({
+ data: {
+ code: this.makeCode(),
+ createdBy: createdByUserId,
+ workspaceIds: JSON.stringify(workspaceIds),
+ },
+ });
+ return { invite, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE INVITE.", error.message);
+ return { invite: null, error: error.message };
+ }
+ },
+
+ deactivate: async function (inviteId = null) {
+ try {
+ await prisma.invites.update({
+ where: { id: Number(inviteId) },
+ data: { status: "disabled" },
+ });
+ return { success: true, error: null };
+ } catch (error) {
+ console.error(error.message);
+ return { success: false, error: error.message };
+ }
+ },
+
+ markClaimed: async function (inviteId = null, user) {
+ try {
+ const invite = await prisma.invites.update({
+ where: { id: Number(inviteId) },
+ data: { status: "claimed", claimedBy: user.id },
+ });
+
+ try {
+ if (!!invite?.workspaceIds) {
+ const { Workspace } = require("./workspace");
+ const { WorkspaceUser } = require("./workspaceUsers");
+ const workspaceIds = (await Workspace.where({})).map(
+ (workspace) => workspace.id
+ );
+ const ids = safeJsonParse(invite.workspaceIds)
+ .map((id) => Number(id))
+ .filter((id) => workspaceIds.includes(id));
+ if (ids.length !== 0) await WorkspaceUser.createMany(user.id, ids);
+ }
+ } catch (e) {
+ console.error(
+ "Could not add user to workspaces automatically",
+ e.message
+ );
+ }
+
+ return { success: true, error: null };
+ } catch (error) {
+ console.error(error.message);
+ return { success: false, error: error.message };
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const invite = await prisma.invites.findFirst({ where: clause });
+ return invite || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.invites.count({ where: clause });
+ return count;
+ } catch (error) {
+ console.error(error.message);
+ return 0;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.invites.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (clause = {}, limit) {
+ try {
+ const invites = await prisma.invites.findMany({
+ where: clause,
+ take: limit || undefined,
+ });
+ return invites;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ whereWithUsers: async function (clause = {}, limit) {
+ const { User } = require("./user");
+ try {
+ const invites = await this.where(clause, limit);
+ for (const invite of invites) {
+ if (invite.claimedBy) {
+ const acceptedUser = await User.get({ id: invite.claimedBy });
+ invite.claimedBy = {
+ id: acceptedUser?.id,
+ username: acceptedUser?.username,
+ };
+ }
+
+ if (invite.createdBy) {
+ const createdUser = await User.get({ id: invite.createdBy });
+ invite.createdBy = {
+ id: createdUser?.id,
+ username: createdUser?.username,
+ };
+ }
+ }
+ return invites;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+};
+
+module.exports = { Invite };
diff --git a/server/models/mobileDevice.js b/server/models/mobileDevice.js
new file mode 100644
index 0000000000000000000000000000000000000000..434f4c3a831a69983ee6e6e9d127ed9b010a87c5
--- /dev/null
+++ b/server/models/mobileDevice.js
@@ -0,0 +1,230 @@
+const prisma = require("../utils/prisma");
+const { v4: uuidv4 } = require("uuid");
+const ip = require("ip");
+
+/**
+ * @typedef {Object} TemporaryMobileDeviceRequest
+ * @property {number|null} userId - User id to associate creation of key with.
+ * @property {number} createdAt - Timestamp of when the token was created.
+ * @property {number} expiresAt - Timestamp of when the token expires.
+ */
+
+/**
+ * Temporary map to store mobile device requests
+ * that are not yet approved. Generates a simple JWT
+ * that expires and is tied to the user (if provided)
+ * This token must be provided during /register event.
+ * @type {Map}
+ */
+const TemporaryMobileDeviceRequests = new Map();
+
+const MobileDevice = {
+ platform: "server",
+ validDeviceOs: ["android"],
+ tablename: "desktop_mobile_devices",
+ writable: ["approved"],
+ validators: {
+ approved: (value) => {
+ if (typeof value !== "boolean") return "Must be a boolean";
+ return null;
+ },
+ },
+
+ /**
+ * Looks up and consumes a temporary token that was registered
+ * Will return null if the token is not found or expired.
+ * @param {string} token - The temporary token to lookup
+ * @returns {TemporaryMobileDeviceRequest|null} Temp token details
+ */
+ tempToken: (token = null) => {
+ try {
+ if (!token || !TemporaryMobileDeviceRequests.has(token)) return null;
+ const tokenData = TemporaryMobileDeviceRequests.get(token);
+ if (tokenData.expiresAt < Date.now()) return null;
+ return tokenData;
+ } catch (error) {
+ return null;
+ } finally {
+ TemporaryMobileDeviceRequests.delete(token);
+ }
+ },
+
+ /**
+ * Registers a temporary token for a mobile device request
+ * This is just using a random token to identify the request
+ * @security Note: If we use a JWT the QR code that encodes it becomes extremely complex
+ * and noisy as QR codes have byte limits that could be exceeded with JWTs. Since this is
+ * a temporary token that is only used to register a device and is short lived we can use UUIDs.
+ * @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode
+ * @returns {string} The temporary token
+ */
+ registerTempToken: function (user = null) {
+ let tokenData = {};
+ if (user) tokenData.userId = user.id;
+ else tokenData.userId = null;
+
+ // Set short lived expiry to this mapping
+ const createdAt = Date.now();
+ tokenData.createdAt = createdAt;
+ tokenData.expiresAt = createdAt + 3 * 60_000;
+
+ const tempToken = uuidv4().split("-").slice(0, 3).join("");
+ TemporaryMobileDeviceRequests.set(tempToken, tokenData);
+
+ // Run this on register since there is no BG task to do this.
+ this.cleanupExpiredTokens();
+ return tempToken;
+ },
+
+ /**
+ * Cleans up expired temporary registration tokens
+ * Should run quick since this mapping is wiped often
+ * and does not live past restarts.
+ */
+ cleanupExpiredTokens: function () {
+ const now = Date.now();
+ for (const [token, data] of TemporaryMobileDeviceRequests.entries()) {
+ if (data.expiresAt < now) TemporaryMobileDeviceRequests.delete(token);
+ }
+ },
+
+ /**
+ * Returns the connection URL for the mobile app to use to connect to the backend.
+ * Since you have to have a valid session to call /mobile/connect-info we can pre-register
+ * a temporary token for the user that is passed back to /mobile/register and can lookup
+ * who a device belongs to so we can scope it's access token.
+ * @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode
+ * @returns {string}
+ */
+ connectionURL: function (user = null) {
+ let baseUrl = "/api/mobile";
+ if (process.env.NODE_ENV === "production") baseUrl = "/api/mobile";
+ else
+ baseUrl = `http://${ip.address()}:${process.env.SERVER_PORT || 3001}/api/mobile`;
+
+ const tempToken = this.registerTempToken(user);
+ baseUrl = `${baseUrl}?t=${tempToken}`;
+ return baseUrl;
+ },
+
+ /**
+ * Creates a new device for the mobile app
+ * @param {object} params - The params to create the device with.
+ * @param {string} params.deviceOs - Device os to associate creation of key with.
+ * @param {string} params.deviceName - Device name to associate creation of key with.
+ * @param {number|null} params.userId - User id to associate creation of key with.
+ * @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>}
+ */
+ create: async function ({ deviceOs, deviceName, userId = null }) {
+ try {
+ if (!deviceOs || !deviceName)
+ return { device: null, error: "Device OS and name are required" };
+ if (!this.validDeviceOs.includes(deviceOs))
+ return { device: null, error: `Invalid device OS - ${deviceOs}` };
+
+ const device = await prisma.desktop_mobile_devices.create({
+ data: {
+ deviceName: String(deviceName),
+ deviceOs: String(deviceOs).toLowerCase(),
+ token: uuidv4(),
+ userId: userId ? Number(userId) : null,
+ },
+ });
+ return { device, error: null };
+ } catch (error) {
+ console.error("Failed to create mobile device", error);
+ return { device: null, error: error.message };
+ }
+ },
+
+ /**
+ * Validated existing API key
+ * @param {string} id - Device id (db id)
+ * @param {object} updates - Updates to apply to device
+ * @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>}
+ */
+ update: async function (id, updates = {}) {
+ const device = await this.get({ id: parseInt(id) });
+ if (!device) return { device: null, error: "Device not found" };
+
+ const validUpdates = {};
+ for (const [key, value] of Object.entries(updates)) {
+ if (!this.writable.includes(key)) continue;
+ const validation = this.validators[key](value);
+ if (validation !== null) return { device: null, error: validation };
+ validUpdates[key] = value;
+ }
+ // If no updates, return the device.
+ if (Object.keys(validUpdates).length === 0) return { device, error: null };
+
+ const updatedDevice = await prisma.desktop_mobile_devices.update({
+ where: { id: device.id },
+ data: validUpdates,
+ });
+ return { device: updatedDevice, error: null };
+ },
+
+ /**
+ * Fetches mobile device by params.
+ * @param {object} clause - Prisma props for search
+ * @returns {Promise}
+ */
+ get: async function (clause = {}, include = null) {
+ try {
+ const device = await prisma.desktop_mobile_devices.findFirst({
+ where: clause,
+ ...(include !== null ? { include } : {}),
+ });
+ return device;
+ } catch (error) {
+ console.error("FAILED TO GET MOBILE DEVICE.", error);
+ return [];
+ }
+ },
+
+ /**
+ * Deletes mobile device by db id.
+ * @param {number} id - database id of mobile device
+ * @returns {Promise<{success: boolean, error:string|null}>}
+ */
+ delete: async function (id) {
+ try {
+ await prisma.desktop_mobile_devices.delete({
+ where: { id: parseInt(id) },
+ });
+ return { success: true, error: null };
+ } catch (error) {
+ console.error("Failed to delete mobile device", error);
+ return { success: false, error: error.message };
+ }
+ },
+
+ /**
+ * Gets mobile devices by params
+ * @param {object} clause
+ * @param {number|null} limit
+ * @param {object|null} orderBy
+ * @returns {Promise}
+ */
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ include = null
+ ) {
+ try {
+ const devices = await prisma.desktop_mobile_devices.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ ...(include !== null ? { include } : {}),
+ });
+ return devices;
+ } catch (error) {
+ console.error("FAILED TO GET MOBILE DEVICES.", error.message);
+ return [];
+ }
+ },
+};
+
+module.exports = { MobileDevice };
diff --git a/server/models/passwordRecovery.js b/server/models/passwordRecovery.js
new file mode 100644
index 0000000000000000000000000000000000000000..1d09d08b306aff9b77be4f6a1f7178a29fef305e
--- /dev/null
+++ b/server/models/passwordRecovery.js
@@ -0,0 +1,115 @@
+const { v4 } = require("uuid");
+const prisma = require("../utils/prisma");
+const bcrypt = require("bcrypt");
+
+const RecoveryCode = {
+ tablename: "recovery_codes",
+ writable: [],
+ create: async function (userId, code) {
+ try {
+ const codeHash = await bcrypt.hash(code, 10);
+ const recoveryCode = await prisma.recovery_codes.create({
+ data: { user_id: userId, code_hash: codeHash },
+ });
+ return { recoveryCode, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE RECOVERY CODE.", error.message);
+ return { recoveryCode: null, error: error.message };
+ }
+ },
+ createMany: async function (data) {
+ try {
+ const recoveryCodes = await prisma.$transaction(
+ data.map((recoveryCode) =>
+ prisma.recovery_codes.create({ data: recoveryCode })
+ )
+ );
+ return { recoveryCodes, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE RECOVERY CODES.", error.message);
+ return { recoveryCodes: null, error: error.message };
+ }
+ },
+ findFirst: async function (clause = {}) {
+ try {
+ const recoveryCode = await prisma.recovery_codes.findFirst({
+ where: clause,
+ });
+ return recoveryCode;
+ } catch (error) {
+ console.error("FAILED TO FIND RECOVERY CODE.", error.message);
+ return null;
+ }
+ },
+ findMany: async function (clause = {}) {
+ try {
+ const recoveryCodes = await prisma.recovery_codes.findMany({
+ where: clause,
+ });
+ return recoveryCodes;
+ } catch (error) {
+ console.error("FAILED TO FIND RECOVERY CODES.", error.message);
+ return null;
+ }
+ },
+ deleteMany: async function (clause = {}) {
+ try {
+ await prisma.recovery_codes.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error("FAILED TO DELETE RECOVERY CODES.", error.message);
+ return false;
+ }
+ },
+ hashesForUser: async function (userId = null) {
+ if (!userId) return [];
+ return (await this.findMany({ user_id: userId })).map(
+ (recovery) => recovery.code_hash
+ );
+ },
+};
+
+const PasswordResetToken = {
+ tablename: "password_reset_tokens",
+ resetExpiryMs: 600_000, // 10 minutes in ms;
+ writable: [],
+ calcExpiry: function () {
+ return new Date(Date.now() + this.resetExpiryMs);
+ },
+ create: async function (userId) {
+ try {
+ const passwordResetToken = await prisma.password_reset_tokens.create({
+ data: { user_id: userId, token: v4(), expiresAt: this.calcExpiry() },
+ });
+ return { passwordResetToken, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE PASSWORD RESET TOKEN.", error.message);
+ return { passwordResetToken: null, error: error.message };
+ }
+ },
+ findUnique: async function (clause = {}) {
+ try {
+ const passwordResetToken = await prisma.password_reset_tokens.findUnique({
+ where: clause,
+ });
+ return passwordResetToken;
+ } catch (error) {
+ console.error("FAILED TO FIND PASSWORD RESET TOKEN.", error.message);
+ return null;
+ }
+ },
+ deleteMany: async function (clause = {}) {
+ try {
+ await prisma.password_reset_tokens.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error("FAILED TO DELETE PASSWORD RESET TOKEN.", error.message);
+ return false;
+ }
+ },
+};
+
+module.exports = {
+ RecoveryCode,
+ PasswordResetToken,
+};
diff --git a/server/models/promptHistory.js b/server/models/promptHistory.js
new file mode 100644
index 0000000000000000000000000000000000000000..3b8f0b79377cbaff70ad4cbcb02f6a8625a57277
--- /dev/null
+++ b/server/models/promptHistory.js
@@ -0,0 +1,107 @@
+const prisma = require("../utils/prisma");
+
+const PromptHistory = {
+ new: async function ({ workspaceId, prompt, modifiedBy = null }) {
+ try {
+ const history = await prisma.prompt_history.create({
+ data: {
+ workspaceId: Number(workspaceId),
+ prompt: String(prompt),
+ modifiedBy: !!modifiedBy ? Number(modifiedBy) : null,
+ },
+ });
+ return { history, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { history: null, message: error.message };
+ }
+ },
+
+ /**
+ * Get the prompt history for a workspace.
+ * @param {number} workspaceId - The ID of the workspace to get prompt history for.
+ * @param {number|null} limit - The maximum number of history items to return.
+ * @param {string|null} orderBy - The field to order the history by.
+ * @returns {Promise>} A promise that resolves to an array of prompt history objects.
+ */
+ forWorkspace: async function (
+ workspaceId = null,
+ limit = null,
+ orderBy = null
+ ) {
+ if (!workspaceId) return [];
+ try {
+ const history = await prisma.prompt_history.findMany({
+ where: { workspaceId: Number(workspaceId) },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null
+ ? { orderBy }
+ : { orderBy: { modifiedAt: "desc" } }),
+ include: {
+ user: {
+ select: {
+ username: true,
+ },
+ },
+ },
+ });
+ return history;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ get: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const history = await prisma.prompt_history.findFirst({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ role: true,
+ },
+ },
+ },
+ });
+ return history || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.prompt_history.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ /**
+ * Utility method to handle prompt changes and create history entries
+ * @param {import('./workspace').Workspace} workspaceData - The workspace object (previous state)
+ * @param {{id: number, role: string}|null} user - The user making the change
+ * @returns {Promise}
+ */
+ handlePromptChange: async function (workspaceData, user = null) {
+ try {
+ await this.new({
+ workspaceId: workspaceData.id,
+ prompt: workspaceData.openAiPrompt, // Store previous prompt as history
+ modifiedBy: user?.id,
+ });
+ } catch (error) {
+ console.error("Failed to create prompt history:", error.message);
+ }
+ },
+};
+
+module.exports = { PromptHistory };
diff --git a/server/models/slashCommandsPresets.js b/server/models/slashCommandsPresets.js
new file mode 100644
index 0000000000000000000000000000000000000000..6aab3651667a4cb6738e5a8fdcb60202fcc9146f
--- /dev/null
+++ b/server/models/slashCommandsPresets.js
@@ -0,0 +1,117 @@
+const { v4 } = require("uuid");
+const prisma = require("../utils/prisma");
+const CMD_REGEX = new RegExp(/[^a-zA-Z0-9_-]/g);
+
+const SlashCommandPresets = {
+ formatCommand: function (command = "") {
+ if (!command || command.length < 2) return `/${v4().split("-")[0]}`;
+
+ let adjustedCmd = command.toLowerCase(); // force lowercase
+ if (!adjustedCmd.startsWith("/")) adjustedCmd = `/${adjustedCmd}`; // Fix if no preceding / is found.
+ return `/${adjustedCmd.slice(1).toLowerCase().replace(CMD_REGEX, "-")}`; // replace any invalid chars with '-'
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const preset = await prisma.slash_command_presets.findFirst({
+ where: clause,
+ });
+ return preset || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (clause = {}, limit) {
+ try {
+ const presets = await prisma.slash_command_presets.findMany({
+ where: clause,
+ take: limit || undefined,
+ });
+ return presets;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ // Command + userId must be unique combination.
+ create: async function (userId = null, presetData = {}) {
+ try {
+ const existingPreset = await this.get({
+ userId: userId ? Number(userId) : null,
+ command: String(presetData.command),
+ });
+
+ if (existingPreset) {
+ console.log(
+ "SlashCommandPresets.create - preset already exists - will not create"
+ );
+ return existingPreset;
+ }
+
+ const preset = await prisma.slash_command_presets.create({
+ data: {
+ ...presetData,
+ // This field (uid) is either the user_id or 0 (for non-multi-user mode).
+ // the UID field enforces the @@unique(userId, command) constraint since
+ // the real relational field (userId) cannot be non-null so this 'dummy' field gives us something
+ // to constrain against within the context of prisma and sqlite that works.
+ uid: userId ? Number(userId) : 0,
+ userId: userId ? Number(userId) : null,
+ },
+ });
+ return preset;
+ } catch (error) {
+ console.error("Failed to create preset", error.message);
+ return null;
+ }
+ },
+
+ getUserPresets: async function (userId = null) {
+ try {
+ return (
+ await prisma.slash_command_presets.findMany({
+ where: { userId: !!userId ? Number(userId) : null },
+ orderBy: { createdAt: "asc" },
+ })
+ )?.map((preset) => ({
+ id: preset.id,
+ command: preset.command,
+ prompt: preset.prompt,
+ description: preset.description,
+ }));
+ } catch (error) {
+ console.error("Failed to get user presets", error.message);
+ return [];
+ }
+ },
+
+ update: async function (presetId = null, presetData = {}) {
+ try {
+ const preset = await prisma.slash_command_presets.update({
+ where: { id: Number(presetId) },
+ data: presetData,
+ });
+ return preset;
+ } catch (error) {
+ console.error("Failed to update preset", error.message);
+ return null;
+ }
+ },
+
+ delete: async function (presetId = null) {
+ try {
+ await prisma.slash_command_presets.delete({
+ where: { id: Number(presetId) },
+ });
+ return true;
+ } catch (error) {
+ console.error("Failed to delete preset", error.message);
+ return false;
+ }
+ },
+};
+
+module.exports.SlashCommandPresets = SlashCommandPresets;
diff --git a/server/models/systemPromptVariables.js b/server/models/systemPromptVariables.js
new file mode 100644
index 0000000000000000000000000000000000000000..bfe94f1962189d4d10371281be78cd28b9c6c33b
--- /dev/null
+++ b/server/models/systemPromptVariables.js
@@ -0,0 +1,286 @@
+const prisma = require("../utils/prisma");
+const moment = require("moment");
+
+/**
+ * @typedef {Object} SystemPromptVariable
+ * @property {number} id
+ * @property {string} key
+ * @property {string|function} value
+ * @property {string} description
+ * @property {'system'|'user'|'static'} type
+ * @property {number} userId
+ * @property {boolean} multiUserRequired
+ */
+
+const SystemPromptVariables = {
+ VALID_TYPES: ["user", "system", "static"],
+ DEFAULT_VARIABLES: [
+ {
+ key: "time",
+ value: () => moment().format("LTS"),
+ description: "Current time",
+ type: "system",
+ multiUserRequired: false,
+ },
+ {
+ key: "date",
+ value: () => moment().format("LL"),
+ description: "Current date",
+ type: "system",
+ multiUserRequired: false,
+ },
+ {
+ key: "datetime",
+ value: () => moment().format("LLLL"),
+ description: "Current date and time",
+ type: "system",
+ multiUserRequired: false,
+ },
+ {
+ key: "user.name",
+ value: async (userId = null) => {
+ if (!userId) return "[User name]";
+ try {
+ const user = await prisma.users.findUnique({
+ where: { id: Number(userId) },
+ select: { username: true },
+ });
+ return user?.username || "[User name is empty or unknown]";
+ } catch (error) {
+ console.error("Error fetching user name:", error);
+ return "[User name is empty or unknown]";
+ }
+ },
+ description: "Current user's username",
+ type: "user",
+ multiUserRequired: true,
+ },
+ {
+ key: "user.bio",
+ value: async (userId = null) => {
+ if (!userId) return "[User bio]";
+ try {
+ const user = await prisma.users.findUnique({
+ where: { id: Number(userId) },
+ select: { bio: true },
+ });
+ return user?.bio || "[User bio is empty]";
+ } catch (error) {
+ console.error("Error fetching user bio:", error);
+ return "[User bio is empty]";
+ }
+ },
+ description: "Current user's bio field from their profile",
+ type: "user",
+ multiUserRequired: true,
+ },
+ ],
+
+ /**
+ * Gets a system prompt variable by its key
+ * @param {string} key
+ * @returns {Promise}
+ */
+ get: async function (key = null) {
+ if (!key) return null;
+ const variable = await prisma.system_prompt_variables.findUnique({
+ where: { key: String(key) },
+ });
+ return variable;
+ },
+
+ /**
+ * Retrieves all system prompt variables with dynamic variables as well
+ * as user defined variables
+ * @param {number|null} userId - the current user ID (determines if in multi-user mode)
+ * @returns {Promise}
+ */
+ getAll: async function (userId = null) {
+ // All user-defined system variables are available to everyone globally since only admins can create them.
+ const userDefinedSystemVariables =
+ await prisma.system_prompt_variables.findMany();
+ const formattedDbVars = userDefinedSystemVariables.map((v) => ({
+ id: v.id,
+ key: v.key,
+ value: v.value,
+ description: v.description,
+ type: v.type,
+ userId: v.userId,
+ }));
+
+ // If userId is not provided, filter the default variables to only include non-multiUserRequired variables
+ // since we wont be able to dynamically inject user-related content.
+ const defaultSystemVariables = !userId
+ ? this.DEFAULT_VARIABLES.filter((v) => !v.multiUserRequired)
+ : this.DEFAULT_VARIABLES;
+
+ return [...defaultSystemVariables, ...formattedDbVars];
+ },
+
+ /**
+ * Creates a new system prompt variable
+ * @param {{ key: string, value: string, description: string, type: string, userId: number }} data
+ * @returns {Promise}
+ */
+ create: async function ({
+ key,
+ value,
+ description = null,
+ type = "static",
+ userId = null,
+ }) {
+ await this._checkVariableKey(key, true);
+ return await prisma.system_prompt_variables.create({
+ data: {
+ key: String(key),
+ value: String(value),
+ description: description ? String(description) : null,
+ type: type ? String(type) : "static",
+ userId: userId ? Number(userId) : null,
+ },
+ });
+ },
+
+ /**
+ * Updates a system prompt variable by its unique database ID
+ * @param {number} id
+ * @param {{ key: string, value: string, description: string }} data
+ * @returns {Promise}
+ */
+ update: async function (id, { key, value, description = null }) {
+ if (!id || !key || !value) return null;
+ const existingRecord = await prisma.system_prompt_variables.findFirst({
+ where: { id: Number(id) },
+ });
+ if (!existingRecord) throw new Error("System prompt variable not found");
+ await this._checkVariableKey(key, false);
+
+ return await prisma.system_prompt_variables.update({
+ where: { id: existingRecord.id },
+ data: {
+ key: String(key),
+ value: String(value),
+ description: description ? String(description) : null,
+ },
+ });
+ },
+
+ /**
+ * Deletes a system prompt variable by its unique database ID
+ * @param {number} id
+ * @returns {Promise}
+ */
+ delete: async function (id = null) {
+ try {
+ await prisma.system_prompt_variables.delete({
+ where: { id: Number(id) },
+ });
+ return true;
+ } catch (error) {
+ console.error("Error deleting variable:", error);
+ return false;
+ }
+ },
+
+ /**
+ * Injects variables into a string based on the user ID (if provided) and the variables available
+ * @param {string} str - the input string to expand variables into
+ * @param {number|null} userId - the user ID to use for dynamic variables
+ * @returns {Promise}
+ */
+ expandSystemPromptVariables: async function (str, userId = null) {
+ if (!str) return str;
+
+ try {
+ const allVariables = await this.getAll(userId);
+ let result = str;
+
+ // Find all variable patterns in the string
+ const matches = str.match(/\{([^}]+)\}/g) || [];
+
+ // Process each match
+ for (const match of matches) {
+ const key = match.substring(1, match.length - 1); // Remove { and }
+
+ // Handle `user.X` variables with current user's data
+ if (key.startsWith("user.")) {
+ const userProp = key.split(".")[1];
+ const variable = allVariables.find((v) => v.key === key);
+
+ if (variable && typeof variable.value === "function") {
+ if (variable.value.constructor.name === "AsyncFunction") {
+ try {
+ const value = await variable.value(userId);
+ result = result.replace(match, value);
+ } catch (error) {
+ console.error(`Error processing user variable ${key}:`, error);
+ result = result.replace(match, `[User ${userProp}]`);
+ }
+ } else {
+ const value = variable.value();
+ result = result.replace(match, value);
+ }
+ } else {
+ result = result.replace(match, `[User ${userProp}]`);
+ }
+ continue;
+ }
+
+ // Handle regular variables (static types)
+ const variable = allVariables.find((v) => v.key === key);
+ if (!variable) continue;
+
+ // For dynamic and system variables, call the function to get the current value
+ if (
+ ["system"].includes(variable.type) &&
+ typeof variable.value === "function"
+ ) {
+ try {
+ if (variable.value.constructor.name === "AsyncFunction") {
+ const value = await variable.value(userId);
+ result = result.replace(match, value);
+ } else {
+ const value = variable.value();
+ result = result.replace(match, value);
+ }
+ } catch (error) {
+ console.error(`Error processing dynamic variable ${key}:`, error);
+ result = result.replace(match, match);
+ }
+ } else {
+ result = result.replace(match, variable.value || match);
+ }
+ }
+ return result;
+ } catch (error) {
+ console.error("Error in expandSystemPromptVariables:", error);
+ return str;
+ }
+ },
+
+ /**
+ * Internal function to check if a variable key is valid
+ * @param {string} key
+ * @param {boolean} checkExisting
+ * @returns {Promise}
+ */
+ _checkVariableKey: async function (key = null, checkExisting = true) {
+ if (!key) throw new Error("Key is required");
+ if (typeof key !== "string") throw new Error("Key must be a string");
+ if (!/^[a-zA-Z0-9_]+$/.test(key))
+ throw new Error("Key must contain only letters, numbers and underscores");
+ if (key.length > 255)
+ throw new Error("Key must be less than 255 characters");
+ if (key.length < 3) throw new Error("Key must be at least 3 characters");
+ if (key.startsWith("user."))
+ throw new Error("Key cannot start with 'user.'");
+ if (key.startsWith("system."))
+ throw new Error("Key cannot start with 'system.'");
+ if (checkExisting && (await this.get(key)) !== null)
+ throw new Error("System prompt variable with this key already exists");
+
+ return true;
+ },
+};
+
+module.exports = { SystemPromptVariables };
diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js
new file mode 100644
index 0000000000000000000000000000000000000000..d11684640fe9d0b15c04bc258c28e5c1df6cd2e4
--- /dev/null
+++ b/server/models/systemSettings.js
@@ -0,0 +1,721 @@
+process.env.NODE_ENV === "development"
+ ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` })
+ : require("dotenv").config();
+
+const { default: slugify } = require("slugify");
+const { isValidUrl, safeJsonParse } = require("../utils/http");
+const prisma = require("../utils/prisma");
+const { v4 } = require("uuid");
+const { MetaGenerator } = require("../utils/boot/MetaGenerator");
+const { PGVector } = require("../utils/vectorDbProviders/pgvector");
+const { NativeEmbedder } = require("../utils/EmbeddingEngines/native");
+const { getBaseLLMProviderModel } = require("../utils/helpers");
+
+function isNullOrNaN(value) {
+ if (value === null) return true;
+ return isNaN(value);
+}
+
+const SystemSettings = {
+ protectedFields: ["multi_user_mode", "hub_api_key"],
+ publicFields: [
+ "footer_data",
+ "support_email",
+ "text_splitter_chunk_size",
+ "text_splitter_chunk_overlap",
+ "max_embed_chunk_size",
+ "agent_search_provider",
+ "agent_sql_connections",
+ "default_agent_skills",
+ "disabled_agent_skills",
+ "imported_agent_skills",
+ "custom_app_name",
+ "feature_flags",
+ "meta_page_title",
+ "meta_page_favicon",
+ ],
+ supportedFields: [
+ "logo_filename",
+ "telemetry_id",
+ "footer_data",
+ "support_email",
+
+ "text_splitter_chunk_size",
+ "text_splitter_chunk_overlap",
+ "agent_search_provider",
+ "default_agent_skills",
+ "disabled_agent_skills",
+ "agent_sql_connections",
+ "custom_app_name",
+
+ // Meta page customization
+ "meta_page_title",
+ "meta_page_favicon",
+
+ // beta feature flags
+ "experimental_live_file_sync",
+
+ // Hub settings
+ "hub_api_key",
+ ],
+ validations: {
+ footer_data: (updates) => {
+ try {
+ const array = JSON.parse(updates)
+ .filter((setting) => isValidUrl(setting.url))
+ .slice(0, 3); // max of 3 items in footer.
+ return JSON.stringify(array);
+ } catch (e) {
+ console.error(`Failed to run validation function on footer_data`);
+ return JSON.stringify([]);
+ }
+ },
+ text_splitter_chunk_size: (update) => {
+ try {
+ if (isNullOrNaN(update)) throw new Error("Value is not a number.");
+ if (Number(update) <= 0) throw new Error("Value must be non-zero.");
+ const { purgeEntireVectorCache } = require("../utils/files");
+ purgeEntireVectorCache();
+ return Number(update);
+ } catch (e) {
+ console.error(
+ `Failed to run validation function on text_splitter_chunk_size`,
+ e.message
+ );
+ return 1000;
+ }
+ },
+ text_splitter_chunk_overlap: (update) => {
+ try {
+ if (isNullOrNaN(update)) throw new Error("Value is not a number");
+ if (Number(update) < 0) throw new Error("Value cannot be less than 0.");
+ const { purgeEntireVectorCache } = require("../utils/files");
+ purgeEntireVectorCache();
+ return Number(update);
+ } catch (e) {
+ console.error(
+ `Failed to run validation function on text_splitter_chunk_overlap`,
+ e.message
+ );
+ return 20;
+ }
+ },
+ agent_search_provider: (update) => {
+ try {
+ if (update === "none") return null;
+ if (
+ ![
+ "google-search-engine",
+ "searchapi",
+ "serper-dot-dev",
+ "bing-search",
+ "serply-engine",
+ "searxng-engine",
+ "tavily-search",
+ "duckduckgo-engine",
+ "exa-search",
+ ].includes(update)
+ )
+ throw new Error("Invalid SERP provider.");
+ return String(update);
+ } catch (e) {
+ console.error(
+ `Failed to run validation function on agent_search_provider`,
+ e.message
+ );
+ return null;
+ }
+ },
+ default_agent_skills: (updates) => {
+ try {
+ const skills = updates.split(",").filter((skill) => !!skill);
+ return JSON.stringify(skills);
+ } catch (e) {
+ console.error(`Could not validate agent skills.`);
+ return JSON.stringify([]);
+ }
+ },
+ disabled_agent_skills: (updates) => {
+ try {
+ const skills = updates.split(",").filter((skill) => !!skill);
+ return JSON.stringify(skills);
+ } catch (e) {
+ console.error(`Could not validate disabled agent skills.`);
+ return JSON.stringify([]);
+ }
+ },
+ agent_sql_connections: async (updates) => {
+ const existingConnections = safeJsonParse(
+ (await SystemSettings.get({ label: "agent_sql_connections" }))?.value,
+ []
+ );
+ try {
+ const updatedConnections = mergeConnections(
+ existingConnections,
+ safeJsonParse(updates, [])
+ );
+ return JSON.stringify(updatedConnections);
+ } catch (e) {
+ console.error(`Failed to merge connections`);
+ return JSON.stringify(existingConnections ?? []);
+ }
+ },
+ experimental_live_file_sync: (update) => {
+ if (typeof update === "boolean")
+ return update === true ? "enabled" : "disabled";
+ if (!["enabled", "disabled"].includes(update)) return "disabled";
+ return String(update);
+ },
+ meta_page_title: (newTitle) => {
+ try {
+ if (typeof newTitle !== "string" || !newTitle) return null;
+ return String(newTitle);
+ } catch {
+ return null;
+ } finally {
+ new MetaGenerator().clearConfig();
+ }
+ },
+ meta_page_favicon: (faviconUrl) => {
+ if (!faviconUrl) return null;
+ try {
+ const url = new URL(faviconUrl);
+ return url.toString();
+ } catch {
+ return null;
+ } finally {
+ new MetaGenerator().clearConfig();
+ }
+ },
+ hub_api_key: (apiKey) => {
+ if (!apiKey) return null;
+ return String(apiKey);
+ },
+ },
+ currentSettings: async function () {
+ const { hasVectorCachedFiles } = require("../utils/files");
+ const llmProvider = process.env.LLM_PROVIDER;
+ const vectorDB = process.env.VECTOR_DB;
+ const embeddingEngine = process.env.EMBEDDING_ENGINE ?? "native";
+ return {
+ // --------------------------------------------------------
+ // General Settings
+ // --------------------------------------------------------
+ RequiresAuth: !!process.env.AUTH_TOKEN,
+ AuthToken: !!process.env.AUTH_TOKEN,
+ JWTSecret: !!process.env.JWT_SECRET,
+ StorageDir: process.env.STORAGE_DIR,
+ MultiUserMode: await this.isMultiUserMode(),
+ DisableTelemetry: process.env.DISABLE_TELEMETRY || "false",
+
+ // --------------------------------------------------------
+ // Embedder Provider Selection Settings & Configs
+ // --------------------------------------------------------
+ EmbeddingEngine: embeddingEngine,
+ HasExistingEmbeddings: await this.hasEmbeddings(), // check if they have any currently embedded documents active in workspaces.
+ HasCachedEmbeddings: hasVectorCachedFiles(), // check if they any currently cached embedded docs.
+ EmbeddingBasePath: process.env.EMBEDDING_BASE_PATH,
+ EmbeddingModelPref:
+ embeddingEngine === "native"
+ ? NativeEmbedder._getEmbeddingModel()
+ : process.env.EMBEDDING_MODEL_PREF,
+ EmbeddingModelMaxChunkLength:
+ process.env.EMBEDDING_MODEL_MAX_CHUNK_LENGTH,
+ VoyageAiApiKey: !!process.env.VOYAGEAI_API_KEY,
+ GenericOpenAiEmbeddingApiKey:
+ !!process.env.GENERIC_OPEN_AI_EMBEDDING_API_KEY,
+ GenericOpenAiEmbeddingMaxConcurrentChunks:
+ process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS || 500,
+ GeminiEmbeddingApiKey: !!process.env.GEMINI_EMBEDDING_API_KEY,
+
+ // --------------------------------------------------------
+ // VectorDB Provider Selection Settings & Configs
+ // --------------------------------------------------------
+ VectorDB: vectorDB,
+ ...this.vectorDBPreferenceKeys(),
+
+ // --------------------------------------------------------
+ // LLM Provider Selection Settings & Configs
+ // --------------------------------------------------------
+ LLMProvider: llmProvider,
+ LLMModel: getBaseLLMProviderModel({ provider: llmProvider }) || null,
+ ...this.llmPreferenceKeys(),
+
+ // --------------------------------------------------------
+ // Whisper (Audio transcription) Selection Settings & Configs
+ // - Currently the only 3rd party is OpenAI, so is OPEN_AI_KEY is set
+ // - then it can be shared.
+ // --------------------------------------------------------
+ WhisperProvider: process.env.WHISPER_PROVIDER || "local",
+ WhisperModelPref:
+ process.env.WHISPER_MODEL_PREF || "Xenova/whisper-small",
+
+ // --------------------------------------------------------
+ // TTS/STT Selection Settings & Configs
+ // - Currently the only 3rd party is OpenAI or the native browser-built in
+ // --------------------------------------------------------
+ TextToSpeechProvider: process.env.TTS_PROVIDER || "native",
+ TTSOpenAIKey: !!process.env.TTS_OPEN_AI_KEY,
+ TTSOpenAIVoiceModel: process.env.TTS_OPEN_AI_VOICE_MODEL,
+
+ // Eleven Labs TTS
+ TTSElevenLabsKey: !!process.env.TTS_ELEVEN_LABS_KEY,
+ TTSElevenLabsVoiceModel: process.env.TTS_ELEVEN_LABS_VOICE_MODEL,
+ // Piper TTS
+ TTSPiperTTSVoiceModel:
+ process.env.TTS_PIPER_VOICE_MODEL ?? "en_US-hfc_female-medium",
+ // OpenAI Generic TTS
+ TTSOpenAICompatibleKey: !!process.env.TTS_OPEN_AI_COMPATIBLE_KEY,
+ TTSOpenAICompatibleModel: process.env.TTS_OPEN_AI_COMPATIBLE_MODEL,
+ TTSOpenAICompatibleVoiceModel:
+ process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL,
+ TTSOpenAICompatibleEndpoint: process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT,
+
+ // --------------------------------------------------------
+ // Agent Settings & Configs
+ // --------------------------------------------------------
+ AgentGoogleSearchEngineId: process.env.AGENT_GSE_CTX || null,
+ AgentGoogleSearchEngineKey: !!process.env.AGENT_GSE_KEY || null,
+ AgentSearchApiKey: !!process.env.AGENT_SEARCHAPI_API_KEY || null,
+ AgentSearchApiEngine: process.env.AGENT_SEARCHAPI_ENGINE || "google",
+ AgentSerperApiKey: !!process.env.AGENT_SERPER_DEV_KEY || null,
+ AgentBingSearchApiKey: !!process.env.AGENT_BING_SEARCH_API_KEY || null,
+ AgentSerplyApiKey: !!process.env.AGENT_SERPLY_API_KEY || null,
+ AgentSearXNGApiUrl: process.env.AGENT_SEARXNG_API_URL || null,
+ AgentTavilyApiKey: !!process.env.AGENT_TAVILY_API_KEY || null,
+ AgentExaApiKey: !!process.env.AGENT_EXA_API_KEY || null,
+
+ // --------------------------------------------------------
+ // Compliance Settings
+ // --------------------------------------------------------
+ // Disable View Chat History for the whole instance.
+ DisableViewChatHistory:
+ "DISABLE_VIEW_CHAT_HISTORY" in process.env || false,
+
+ // --------------------------------------------------------
+ // Simple SSO Settings
+ // --------------------------------------------------------
+ SimpleSSOEnabled: "SIMPLE_SSO_ENABLED" in process.env || false,
+ SimpleSSONoLogin: "SIMPLE_SSO_NO_LOGIN" in process.env || false,
+ SimpleSSONoLoginRedirect: this.simpleSSO.noLoginRedirect(),
+ };
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const setting = await prisma.system_settings.findFirst({ where: clause });
+ return setting || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ getValueOrFallback: async function (clause = {}, fallback = null) {
+ try {
+ return (await this.get(clause))?.value ?? fallback;
+ } catch (error) {
+ console.error(error.message);
+ return fallback;
+ }
+ },
+
+ where: async function (clause = {}, limit) {
+ try {
+ const settings = await prisma.system_settings.findMany({
+ where: clause,
+ take: limit || undefined,
+ });
+ return settings;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ // Can take generic keys and will pre-filter invalid keys
+ // from the set before sending to the explicit update function
+ // that will then enforce validations as well.
+ updateSettings: async function (updates = {}) {
+ const validFields = Object.keys(updates).filter((key) =>
+ this.supportedFields.includes(key)
+ );
+
+ Object.entries(updates).forEach(([key]) => {
+ if (validFields.includes(key)) return;
+ delete updates[key];
+ });
+
+ return this._updateSettings(updates);
+ },
+
+ // Explicit update of settings + key validations.
+ // Only use this method when directly setting a key value
+ // that takes no user input for the keys being modified.
+ _updateSettings: async function (updates = {}) {
+ try {
+ const updatePromises = [];
+ for (const key of Object.keys(updates)) {
+ let validatedValue = updates[key];
+ if (this.validations.hasOwnProperty(key)) {
+ if (this.validations[key].constructor.name === "AsyncFunction") {
+ validatedValue = await this.validations[key](updates[key]);
+ } else {
+ validatedValue = this.validations[key](updates[key]);
+ }
+ }
+
+ updatePromises.push(
+ prisma.system_settings.upsert({
+ where: { label: key },
+ update: {
+ value: validatedValue === null ? null : String(validatedValue),
+ },
+ create: {
+ label: key,
+ value: validatedValue === null ? null : String(validatedValue),
+ },
+ })
+ );
+ }
+
+ await Promise.all(updatePromises);
+ return { success: true, error: null };
+ } catch (error) {
+ console.error("FAILED TO UPDATE SYSTEM SETTINGS", error.message);
+ return { success: false, error: error.message };
+ }
+ },
+
+ isMultiUserMode: async function () {
+ try {
+ const setting = await this.get({ label: "multi_user_mode" });
+ return setting?.value === "true";
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ currentLogoFilename: async function () {
+ try {
+ const setting = await this.get({ label: "logo_filename" });
+ return setting?.value || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ hasEmbeddings: async function () {
+ try {
+ const { Document } = require("./documents");
+ const count = await Document.count({}, 1);
+ return count > 0;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ vectorDBPreferenceKeys: function () {
+ return {
+ // Pinecone DB Keys
+ PineConeKey: !!process.env.PINECONE_API_KEY,
+ PineConeIndex: process.env.PINECONE_INDEX,
+
+ // Chroma DB Keys
+ ChromaEndpoint: process.env.CHROMA_ENDPOINT,
+ ChromaApiHeader: process.env.CHROMA_API_HEADER,
+ ChromaApiKey: !!process.env.CHROMA_API_KEY,
+
+ // ChromaCloud DB Keys
+ ChromaCloudApiKey: !!process.env.CHROMACLOUD_API_KEY,
+ ChromaCloudTenant: process.env.CHROMACLOUD_TENANT,
+ ChromaCloudDatabase: process.env.CHROMACLOUD_DATABASE,
+
+ // Weaviate DB Keys
+ WeaviateEndpoint: process.env.WEAVIATE_ENDPOINT,
+ WeaviateApiKey: process.env.WEAVIATE_API_KEY,
+
+ // QDrant DB Keys
+ QdrantEndpoint: process.env.QDRANT_ENDPOINT,
+ QdrantApiKey: process.env.QDRANT_API_KEY,
+
+ // Milvus DB Keys
+ MilvusAddress: process.env.MILVUS_ADDRESS,
+ MilvusUsername: process.env.MILVUS_USERNAME,
+ MilvusPassword: !!process.env.MILVUS_PASSWORD,
+
+ // Zilliz DB Keys
+ ZillizEndpoint: process.env.ZILLIZ_ENDPOINT,
+ ZillizApiToken: process.env.ZILLIZ_API_TOKEN,
+
+ // AstraDB Keys
+ AstraDBApplicationToken: process?.env?.ASTRA_DB_APPLICATION_TOKEN,
+ AstraDBEndpoint: process?.env?.ASTRA_DB_ENDPOINT,
+
+ // PGVector Keys
+ PGVectorConnectionString: !!PGVector.connectionString() || false,
+ PGVectorTableName: PGVector.tableName(),
+ };
+ },
+
+ llmPreferenceKeys: function () {
+ return {
+ // OpenAI Keys
+ OpenAiKey: !!process.env.OPEN_AI_KEY,
+ OpenAiModelPref: process.env.OPEN_MODEL_PREF || "gpt-4o",
+
+ // Azure + OpenAI Keys
+ AzureOpenAiEndpoint: process.env.AZURE_OPENAI_ENDPOINT,
+ AzureOpenAiKey: !!process.env.AZURE_OPENAI_KEY,
+ AzureOpenAiModelPref: process.env.OPEN_MODEL_PREF,
+ AzureOpenAiEmbeddingModelPref: process.env.EMBEDDING_MODEL_PREF,
+ AzureOpenAiTokenLimit: process.env.AZURE_OPENAI_TOKEN_LIMIT || 4096,
+ AzureOpenAiModelType: process.env.AZURE_OPENAI_MODEL_TYPE || "default",
+
+ // Anthropic Keys
+ AnthropicApiKey: !!process.env.ANTHROPIC_API_KEY,
+ AnthropicModelPref: process.env.ANTHROPIC_MODEL_PREF || "claude-2",
+
+ // Gemini Keys
+ GeminiLLMApiKey: !!process.env.GEMINI_API_KEY,
+ GeminiLLMModelPref:
+ process.env.GEMINI_LLM_MODEL_PREF || "gemini-2.0-flash-lite",
+ GeminiSafetySetting:
+ process.env.GEMINI_SAFETY_SETTING || "BLOCK_MEDIUM_AND_ABOVE",
+
+ // LMStudio Keys
+ LMStudioBasePath: process.env.LMSTUDIO_BASE_PATH,
+ LMStudioTokenLimit: process.env.LMSTUDIO_MODEL_TOKEN_LIMIT,
+ LMStudioModelPref: process.env.LMSTUDIO_MODEL_PREF,
+
+ // LocalAI Keys
+ LocalAiApiKey: !!process.env.LOCAL_AI_API_KEY,
+ LocalAiBasePath: process.env.LOCAL_AI_BASE_PATH,
+ LocalAiModelPref: process.env.LOCAL_AI_MODEL_PREF,
+ LocalAiTokenLimit: process.env.LOCAL_AI_MODEL_TOKEN_LIMIT,
+
+ // Ollama LLM Keys
+ OllamaLLMAuthToken: !!process.env.OLLAMA_AUTH_TOKEN,
+ OllamaLLMBasePath: process.env.OLLAMA_BASE_PATH,
+ OllamaLLMModelPref: process.env.OLLAMA_MODEL_PREF,
+ OllamaLLMTokenLimit: process.env.OLLAMA_MODEL_TOKEN_LIMIT,
+ OllamaLLMKeepAliveSeconds: process.env.OLLAMA_KEEP_ALIVE_TIMEOUT ?? 300,
+ OllamaLLMPerformanceMode: process.env.OLLAMA_PERFORMANCE_MODE ?? "base",
+
+ // Novita LLM Keys
+ NovitaLLMApiKey: !!process.env.NOVITA_LLM_API_KEY,
+ NovitaLLMModelPref: process.env.NOVITA_LLM_MODEL_PREF,
+ NovitaLLMTimeout: process.env.NOVITA_LLM_TIMEOUT_MS,
+
+ // TogetherAI Keys
+ TogetherAiApiKey: !!process.env.TOGETHER_AI_API_KEY,
+ TogetherAiModelPref: process.env.TOGETHER_AI_MODEL_PREF,
+
+ // Fireworks AI API Keys
+ FireworksAiLLMApiKey: !!process.env.FIREWORKS_AI_LLM_API_KEY,
+ FireworksAiLLMModelPref: process.env.FIREWORKS_AI_LLM_MODEL_PREF,
+
+ // Perplexity AI Keys
+ PerplexityApiKey: !!process.env.PERPLEXITY_API_KEY,
+ PerplexityModelPref: process.env.PERPLEXITY_MODEL_PREF,
+
+ // OpenRouter Keys
+ OpenRouterApiKey: !!process.env.OPENROUTER_API_KEY,
+ OpenRouterModelPref: process.env.OPENROUTER_MODEL_PREF,
+ OpenRouterTimeout: process.env.OPENROUTER_TIMEOUT_MS,
+
+ // Mistral AI (API) Keys
+ MistralApiKey: !!process.env.MISTRAL_API_KEY,
+ MistralModelPref: process.env.MISTRAL_MODEL_PREF,
+
+ // Groq AI API Keys
+ GroqApiKey: !!process.env.GROQ_API_KEY,
+ GroqModelPref: process.env.GROQ_MODEL_PREF,
+
+ // HuggingFace Dedicated Inference
+ HuggingFaceLLMEndpoint: process.env.HUGGING_FACE_LLM_ENDPOINT,
+ HuggingFaceLLMAccessToken: !!process.env.HUGGING_FACE_LLM_API_KEY,
+ HuggingFaceLLMTokenLimit: process.env.HUGGING_FACE_LLM_TOKEN_LIMIT,
+
+ // KoboldCPP Keys
+ KoboldCPPModelPref: process.env.KOBOLD_CPP_MODEL_PREF,
+ KoboldCPPBasePath: process.env.KOBOLD_CPP_BASE_PATH,
+ KoboldCPPTokenLimit: process.env.KOBOLD_CPP_MODEL_TOKEN_LIMIT,
+ KoboldCPPMaxTokens: process.env.KOBOLD_CPP_MAX_TOKENS,
+
+ // Text Generation Web UI Keys
+ TextGenWebUIBasePath: process.env.TEXT_GEN_WEB_UI_BASE_PATH,
+ TextGenWebUITokenLimit: process.env.TEXT_GEN_WEB_UI_MODEL_TOKEN_LIMIT,
+ TextGenWebUIAPIKey: !!process.env.TEXT_GEN_WEB_UI_API_KEY,
+
+ // LiteLLM Keys
+ LiteLLMModelPref: process.env.LITE_LLM_MODEL_PREF,
+ LiteLLMTokenLimit: process.env.LITE_LLM_MODEL_TOKEN_LIMIT,
+ LiteLLMBasePath: process.env.LITE_LLM_BASE_PATH,
+ LiteLLMApiKey: !!process.env.LITE_LLM_API_KEY,
+
+ // Moonshot AI Keys
+ MoonshotAiApiKey: !!process.env.MOONSHOT_AI_API_KEY,
+ MoonshotAiModelPref:
+ process.env.MOONSHOT_AI_MODEL_PREF || "moonshot-v1-32k",
+
+ // Generic OpenAI Keys
+ GenericOpenAiBasePath: process.env.GENERIC_OPEN_AI_BASE_PATH,
+ GenericOpenAiModelPref: process.env.GENERIC_OPEN_AI_MODEL_PREF,
+ GenericOpenAiTokenLimit: process.env.GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT,
+ GenericOpenAiKey: !!process.env.GENERIC_OPEN_AI_API_KEY,
+ GenericOpenAiMaxTokens: process.env.GENERIC_OPEN_AI_MAX_TOKENS,
+
+ AwsBedrockLLMConnectionMethod:
+ process.env.AWS_BEDROCK_LLM_CONNECTION_METHOD || "iam",
+ AwsBedrockLLMAccessKeyId: !!process.env.AWS_BEDROCK_LLM_ACCESS_KEY_ID,
+ AwsBedrockLLMAccessKey: !!process.env.AWS_BEDROCK_LLM_ACCESS_KEY,
+ AwsBedrockLLMSessionToken: !!process.env.AWS_BEDROCK_LLM_SESSION_TOKEN,
+ AwsBedrockLLMRegion: process.env.AWS_BEDROCK_LLM_REGION,
+ AwsBedrockLLMModel: process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE,
+ AwsBedrockLLMTokenLimit:
+ process.env.AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT || 8192,
+ AwsBedrockLLMMaxOutputTokens:
+ process.env.AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS || 4096,
+
+ // Cohere API Keys
+ CohereApiKey: !!process.env.COHERE_API_KEY,
+ CohereModelPref: process.env.COHERE_MODEL_PREF,
+
+ // DeepSeek API Keys
+ DeepSeekApiKey: !!process.env.DEEPSEEK_API_KEY,
+ DeepSeekModelPref: process.env.DEEPSEEK_MODEL_PREF,
+
+ // APIPie LLM API Keys
+ ApipieLLMApiKey: !!process.env.APIPIE_LLM_API_KEY,
+ ApipieLLMModelPref: process.env.APIPIE_LLM_MODEL_PREF,
+
+ // xAI LLM API Keys
+ XAIApiKey: !!process.env.XAI_LLM_API_KEY,
+ XAIModelPref: process.env.XAI_LLM_MODEL_PREF,
+
+ // NVIDIA NIM Keys
+ NvidiaNimLLMBasePath: process.env.NVIDIA_NIM_LLM_BASE_PATH,
+ NvidiaNimLLMModelPref: process.env.NVIDIA_NIM_LLM_MODEL_PREF,
+ NvidiaNimLLMTokenLimit: process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT,
+
+ // PPIO API keys
+ PPIOApiKey: !!process.env.PPIO_API_KEY,
+ PPIOModelPref: process.env.PPIO_MODEL_PREF,
+
+ // Dell Pro AI Studio Keys
+ DellProAiStudioBasePath: process.env.DPAIS_LLM_BASE_PATH,
+ DellProAiStudioModelPref: process.env.DPAIS_LLM_MODEL_PREF,
+ DellProAiStudioTokenLimit:
+ process.env.DPAIS_LLM_MODEL_TOKEN_LIMIT ?? 4096,
+
+ // CometAPI LLM Keys
+ CometApiLLMApiKey: !!process.env.COMETAPI_LLM_API_KEY,
+ CometApiLLMModelPref: process.env.COMETAPI_LLM_MODEL_PREF,
+ CometApiLLMTimeout: process.env.COMETAPI_LLM_TIMEOUT_MS,
+ };
+ },
+
+ // For special retrieval of a key setting that does not expose any credential information
+ brief: {
+ agent_sql_connections: async function () {
+ const setting = await SystemSettings.get({
+ label: "agent_sql_connections",
+ });
+ if (!setting) return [];
+ return safeJsonParse(setting.value, []).map((dbConfig) => {
+ const { connectionString, ...rest } = dbConfig;
+ return rest;
+ });
+ },
+ },
+ getFeatureFlags: async function () {
+ return {
+ experimental_live_file_sync:
+ (await SystemSettings.get({ label: "experimental_live_file_sync" }))
+ ?.value === "enabled",
+ };
+ },
+
+ /**
+ * Get user configured Community Hub Settings
+ * Connection key is used to authenticate with the Community Hub API
+ * for your account.
+ * @returns {Promise<{connectionKey: string}>}
+ */
+ hubSettings: async function () {
+ try {
+ const hubKey = await this.get({ label: "hub_api_key" });
+ return { connectionKey: hubKey?.value || null };
+ } catch (error) {
+ console.error(error.message);
+ return { connectionKey: null };
+ }
+ },
+
+ simpleSSO: {
+ /**
+ * Gets the no login redirect URL. If the conditions below are not met, this will return null.
+ * - If simple SSO is not enabled.
+ * - If simple SSO login page is not disabled.
+ * - If the no login redirect is not a valid URL or is not set.
+ * @returns {string | null}
+ */
+ noLoginRedirect: () => {
+ if (!("SIMPLE_SSO_ENABLED" in process.env)) return null; // if simple SSO is not enabled, return null
+ if (!("SIMPLE_SSO_NO_LOGIN" in process.env)) return null; // if the no login config is not set, return null
+ if (!("SIMPLE_SSO_NO_LOGIN_REDIRECT" in process.env)) return null; // if the no login redirect is not set, return null
+
+ try {
+ let url = new URL(process.env.SIMPLE_SSO_NO_LOGIN_REDIRECT);
+ return url.toString();
+ } catch {}
+
+ // if the no login redirect is not a valid URL or is not set, return null
+ return null;
+ },
+ },
+};
+
+function mergeConnections(existingConnections = [], updates = []) {
+ let updatedConnections = [...existingConnections];
+ const existingDbIds = existingConnections.map((conn) => conn.database_id);
+
+ // First remove all 'action:remove' candidates from existing connections.
+ const toRemove = updates
+ .filter((conn) => conn.action === "remove")
+ .map((conn) => conn.database_id);
+ updatedConnections = updatedConnections.filter(
+ (conn) => !toRemove.includes(conn.database_id)
+ );
+
+ // Next add all 'action:add' candidates into the updatedConnections; We DO NOT validate the connection strings.
+ // but we do validate their database_id is unique.
+ updates
+ .filter((conn) => conn.action === "add")
+ .forEach((update) => {
+ if (!update.connectionString) return; // invalid connection string
+
+ // Remap name to be unique to entire set.
+ if (existingDbIds.includes(update.database_id)) {
+ update.database_id = slugify(
+ `${update.database_id}-${v4().slice(0, 4)}`
+ );
+ } else {
+ update.database_id = slugify(update.database_id);
+ }
+
+ updatedConnections.push({
+ engine: update.engine,
+ database_id: update.database_id,
+ connectionString: update.connectionString,
+ });
+ });
+
+ return updatedConnections;
+}
+
+module.exports.SystemSettings = SystemSettings;
diff --git a/server/models/telemetry.js b/server/models/telemetry.js
new file mode 100644
index 0000000000000000000000000000000000000000..0257d030683aeb0ac1884305c5935c5b1b780650
--- /dev/null
+++ b/server/models/telemetry.js
@@ -0,0 +1,147 @@
+const { v4 } = require("uuid");
+const { SystemSettings } = require("./systemSettings");
+
+// Map of events and last sent time to check if the event is on cooldown
+// This will be cleared on server restart - but that is fine since it is mostly to just
+// prevent spamming the logs.
+const TelemetryCooldown = new Map();
+
+const Telemetry = {
+ // Write-only key. It can't read events or any of your other data, so it's safe to use in public apps.
+ pubkey: "phc_9qu7QLpV8L84P3vFmEiZxL020t2EqIubP7HHHxrSsqS",
+ stubDevelopmentEvents: true, // [DO NOT TOUCH] Core team only.
+ label: "telemetry_id",
+ /*
+ Key value pairs of events that should be debounced to prevent spamming the logs.
+ This should be used for events that could be triggered in rapid succession that are not useful to atomically log.
+ The value is the number of seconds to debounce the event
+ */
+ debounced: {
+ sent_chat: 1800,
+ agent_chat_sent: 1800,
+ agent_chat_started: 1800,
+ agent_tool_call: 1800,
+
+ // Document mgmt events
+ document_uploaded: 30,
+ documents_embedded_in_workspace: 30,
+ link_uploaded: 30,
+ raw_document_uploaded: 30,
+ document_parsed: 30,
+ },
+
+ id: async function () {
+ const result = await SystemSettings.get({ label: this.label });
+ return result?.value || null;
+ },
+
+ connect: async function () {
+ const client = this.client();
+ const distinctId = await this.findOrCreateId();
+ return { client, distinctId };
+ },
+
+ isDev: function () {
+ return process.env.NODE_ENV === "development" && this.stubDevelopmentEvents;
+ },
+
+ client: function () {
+ if (process.env.DISABLE_TELEMETRY === "true" || this.isDev()) return null;
+ const { PostHog } = require("posthog-node");
+ return new PostHog(this.pubkey);
+ },
+
+ runtime: function () {
+ if (process.env.ANYTHING_LLM_RUNTIME === "docker") return "docker";
+ if (process.env.NODE_ENV === "production") return "production";
+ return "other";
+ },
+
+ /**
+ * Checks if the event is on cooldown
+ * @param {string} event - The event to check
+ * @returns {boolean} - True if the event is on cooldown, false otherwise
+ */
+ isOnCooldown: function (event) {
+ // If the event is not debounced, return false
+ if (!this.debounced[event]) return false;
+
+ // If the event is not in the cooldown map, return false
+ const lastSent = TelemetryCooldown.get(event);
+ if (!lastSent) return false;
+
+ // If the event is in the cooldown map, check if it has expired
+ const now = Date.now();
+ const cooldown = this.debounced[event] * 1000;
+ return now - lastSent < cooldown;
+ },
+
+ /**
+ * Marks the event as on cooldown - will check if the event is debounced first
+ * @param {string} event - The event to mark
+ */
+ markOnCooldown: function (event) {
+ if (!this.debounced[event]) return;
+ TelemetryCooldown.set(event, Date.now());
+ },
+
+ sendTelemetry: async function (
+ event,
+ eventProperties = {},
+ subUserId = null,
+ silent = false
+ ) {
+ try {
+ const { client, distinctId: systemId } = await this.connect();
+ if (!client) return;
+ const distinctId = !!subUserId ? `${systemId}::${subUserId}` : systemId;
+ const properties = { ...eventProperties, runtime: this.runtime() };
+
+ // If the event is on cooldown, return
+ if (this.isOnCooldown(event)) return;
+
+ // Silence some events to keep logs from being too messy in production
+ // eg: Tool calls from agents spamming the logs.
+ if (!silent) {
+ console.log(`\x1b[32m[TELEMETRY SENT]\x1b[0m`, {
+ event,
+ distinctId,
+ properties,
+ });
+ }
+
+ client.capture({
+ event,
+ distinctId,
+ properties,
+ });
+ } catch {
+ return;
+ } finally {
+ // Mark the event as on cooldown if needed
+ this.markOnCooldown(event);
+ }
+ },
+
+ flush: async function () {
+ const client = this.client();
+ if (!client) return;
+ await client.shutdownAsync();
+ },
+
+ setUid: async function () {
+ const newId = v4();
+ await SystemSettings._updateSettings({ [this.label]: newId });
+ return newId;
+ },
+
+ findOrCreateId: async function () {
+ let currentId = await this.id();
+ if (currentId) return currentId;
+
+ currentId = await this.setUid();
+ return currentId;
+ },
+};
+
+module.exports = { Telemetry };
diff --git a/server/models/temporaryAuthToken.js b/server/models/temporaryAuthToken.js
new file mode 100644
index 0000000000000000000000000000000000000000..327c69bb5946e5969e5bfad6a07b761fe151be8d
--- /dev/null
+++ b/server/models/temporaryAuthToken.js
@@ -0,0 +1,104 @@
+const { makeJWT } = require("../utils/http");
+const prisma = require("../utils/prisma");
+
+/**
+ * Temporary auth tokens are used for simple SSO.
+ * They simply enable the ability for a time-based token to be used in the query of the /sso/login URL
+ * to login as a user without the need of a username and password. These tokens are single-use and expire.
+ */
+const TemporaryAuthToken = {
+ expiry: 1000 * 60 * 6, // 1 hour
+ tablename: "temporary_auth_tokens",
+ writable: [],
+
+ makeTempToken: () => {
+ const uuidAPIKey = require("uuid-apikey");
+ return `allm-tat-${uuidAPIKey.create().apiKey}`;
+ },
+
+ /**
+ * Issues a temporary auth token for a user via its ID.
+ * @param {number} userId
+ * @returns {Promise<{token: string|null, error: string | null}>}
+ */
+ issue: async function (userId = null) {
+ if (!userId)
+ throw new Error("User ID is required to issue a temporary auth token.");
+ await this.invalidateUserTokens(userId);
+
+ try {
+ const token = this.makeTempToken();
+ const expiresAt = new Date(Date.now() + this.expiry);
+ await prisma.temporary_auth_tokens.create({
+ data: {
+ token,
+ expiresAt,
+ userId: Number(userId),
+ },
+ });
+
+ return { token, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE TEMPORARY AUTH TOKEN.", error.message);
+ return { token: null, error: error.message };
+ }
+ },
+
+ /**
+ * Invalidates (deletes) all temporary auth tokens for a user via their ID.
+ * @param {number} userId
+ * @returns {Promise}
+ */
+ invalidateUserTokens: async function (userId) {
+ if (!userId)
+ throw new Error(
+ "User ID is required to invalidate temporary auth tokens."
+ );
+ await prisma.temporary_auth_tokens.deleteMany({
+ where: { userId: Number(userId) },
+ });
+ return true;
+ },
+
+ /**
+ * Validates a temporary auth token and returns the session token
+ * to be set in the browser localStorage for authentication.
+ * @param {string} publicToken - the token to validate against
+ * @returns {Promise<{sessionToken: string|null, token: import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | null, error: string | null}>}
+ */
+ validate: async function (publicToken = "") {
+ /** @type {import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | undefined | null} **/
+ let token;
+
+ try {
+ if (!publicToken)
+ throw new Error(
+ "Public token is required to validate a temporary auth token."
+ );
+ token = await prisma.temporary_auth_tokens.findUnique({
+ where: { token: String(publicToken) },
+ include: { user: true },
+ });
+ if (!token) throw new Error("Invalid token.");
+ if (token.expiresAt < new Date()) throw new Error("Token expired.");
+ if (token.user.suspended) throw new Error("User account suspended.");
+
+ // Create a new session token for the user valid for 30 days
+ const sessionToken = makeJWT(
+ { id: token.user.id, username: token.user.username },
+ process.env.JWT_EXPIRY
+ );
+
+ return { sessionToken, token, error: null };
+ } catch (error) {
+ console.error("FAILED TO VALIDATE TEMPORARY AUTH TOKEN.", error.message);
+ return { sessionToken: null, token: null, error: error.message };
+ } finally {
+ // Delete the token after it has been used under all circumstances if it was retrieved
+ if (token)
+ await prisma.temporary_auth_tokens.delete({ where: { id: token.id } });
+ }
+ },
+};
+
+module.exports = { TemporaryAuthToken };
diff --git a/server/models/user.js b/server/models/user.js
new file mode 100644
index 0000000000000000000000000000000000000000..cfcf2ecd8f29e1a7d898328ae34eeb07c332564a
--- /dev/null
+++ b/server/models/user.js
@@ -0,0 +1,333 @@
+const prisma = require("../utils/prisma");
+const { EventLogs } = require("./eventLogs");
+
+/**
+ * @typedef {Object} User
+ * @property {number} id
+ * @property {string} username
+ * @property {string} password
+ * @property {string} pfpFilename
+ * @property {string} role
+ * @property {boolean} suspended
+ * @property {number|null} dailyMessageLimit
+ */
+
+const User = {
+ usernameRegex: new RegExp(/^[a-z0-9_\-.]+$/),
+ writable: [
+ // Used for generic updates so we can validate keys in request body
+ "username",
+ "password",
+ "pfpFilename",
+ "role",
+ "suspended",
+ "dailyMessageLimit",
+ "bio",
+ ],
+ validations: {
+ username: (newValue = "") => {
+ try {
+ if (String(newValue).length > 100)
+ throw new Error("Username cannot be longer than 100 characters");
+ if (String(newValue).length < 2)
+ throw new Error("Username must be at least 2 characters");
+ return String(newValue);
+ } catch (e) {
+ throw new Error(e.message);
+ }
+ },
+ role: (role = "default") => {
+ const VALID_ROLES = ["default", "admin", "manager"];
+ if (!VALID_ROLES.includes(role)) {
+ throw new Error(
+ `Invalid role. Allowed roles are: ${VALID_ROLES.join(", ")}`
+ );
+ }
+ return String(role);
+ },
+ dailyMessageLimit: (dailyMessageLimit = null) => {
+ if (dailyMessageLimit === null) return null;
+ const limit = Number(dailyMessageLimit);
+ if (isNaN(limit) || limit < 1) {
+ throw new Error(
+ "Daily message limit must be null or a number greater than or equal to 1"
+ );
+ }
+ return limit;
+ },
+ bio: (bio = "") => {
+ if (!bio || typeof bio !== "string") return "";
+ if (bio.length > 1000)
+ throw new Error("Bio cannot be longer than 1,000 characters");
+ return String(bio);
+ },
+ },
+ // validations for the above writable fields.
+ castColumnValue: function (key, value) {
+ switch (key) {
+ case "suspended":
+ return Number(Boolean(value));
+ case "dailyMessageLimit":
+ return value === null ? null : Number(value);
+ default:
+ return String(value);
+ }
+ },
+
+ filterFields: function (user = {}) {
+ const { password, ...rest } = user;
+ return { ...rest };
+ },
+
+ create: async function ({
+ username,
+ password,
+ role = "default",
+ dailyMessageLimit = null,
+ bio = "",
+ }) {
+ const passwordCheck = this.checkPasswordComplexity(password);
+ if (!passwordCheck.checkedOK) {
+ return { user: null, error: passwordCheck.error };
+ }
+
+ try {
+ // Do not allow new users to bypass validation
+ if (!this.usernameRegex.test(username))
+ throw new Error(
+ "Username must only contain lowercase letters, periods, numbers, underscores, and hyphens with no spaces"
+ );
+
+ const bcrypt = require("bcrypt");
+ const hashedPassword = bcrypt.hashSync(password, 10);
+ const user = await prisma.users.create({
+ data: {
+ username: this.validations.username(username),
+ password: hashedPassword,
+ role: this.validations.role(role),
+ bio: this.validations.bio(bio),
+ dailyMessageLimit:
+ this.validations.dailyMessageLimit(dailyMessageLimit),
+ },
+ });
+ return { user: this.filterFields(user), error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE USER.", error.message);
+ return { user: null, error: error.message };
+ }
+ },
+ // Log the changes to a user object, but omit sensitive fields
+ // that are not meant to be logged.
+ loggedChanges: function (updates, prev = {}) {
+ const changes = {};
+ const sensitiveFields = ["password"];
+
+ Object.keys(updates).forEach((key) => {
+ if (!sensitiveFields.includes(key) && updates[key] !== prev[key]) {
+ changes[key] = `${prev[key]} => ${updates[key]}`;
+ }
+ });
+
+ return changes;
+ },
+
+ update: async function (userId, updates = {}) {
+ try {
+ if (!userId) throw new Error("No user id provided for update");
+ const currentUser = await prisma.users.findUnique({
+ where: { id: parseInt(userId) },
+ });
+ if (!currentUser) return { success: false, error: "User not found" };
+ // Removes non-writable fields for generic updates
+ // and force-casts to the proper type;
+ Object.entries(updates).forEach(([key, value]) => {
+ if (this.writable.includes(key)) {
+ if (this.validations.hasOwnProperty(key)) {
+ updates[key] = this.validations[key](
+ this.castColumnValue(key, value)
+ );
+ } else {
+ updates[key] = this.castColumnValue(key, value);
+ }
+ return;
+ }
+ delete updates[key];
+ });
+
+ if (Object.keys(updates).length === 0)
+ return { success: false, error: "No valid updates applied." };
+
+ // Handle password specific updates
+ if (updates.hasOwnProperty("password")) {
+ const passwordCheck = this.checkPasswordComplexity(updates.password);
+ if (!passwordCheck.checkedOK) {
+ return { success: false, error: passwordCheck.error };
+ }
+ const bcrypt = require("bcrypt");
+ updates.password = bcrypt.hashSync(updates.password, 10);
+ }
+
+ if (
+ updates.hasOwnProperty("username") &&
+ currentUser.username !== updates.username &&
+ !this.usernameRegex.test(updates.username)
+ )
+ return {
+ success: false,
+ error:
+ "Username must only contain lowercase letters, periods, numbers, underscores, and hyphens with no spaces",
+ };
+
+ const user = await prisma.users.update({
+ where: { id: parseInt(userId) },
+ data: updates,
+ });
+
+ await EventLogs.logEvent(
+ "user_updated",
+ {
+ username: user.username,
+ changes: this.loggedChanges(updates, currentUser),
+ },
+ userId
+ );
+ return { success: true, error: null };
+ } catch (error) {
+ console.error(error.message);
+ return { success: false, error: error.message };
+ }
+ },
+
+ // Explicit direct update of user object.
+ // Only use this method when directly setting a key value
+ // that takes no user input for the keys being modified.
+ _update: async function (id = null, data = {}) {
+ if (!id) throw new Error("No user id provided for update");
+
+ try {
+ const user = await prisma.users.update({
+ where: { id },
+ data,
+ });
+ return { user, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { user: null, message: error.message };
+ }
+ },
+
+ /**
+ * Returns a user object based on the clause provided.
+ * @param {Object} clause - The clause to use to find the user.
+ * @returns {Promise} The user object or null if not found.
+ */
+ get: async function (clause = {}) {
+ try {
+ const user = await prisma.users.findFirst({ where: clause });
+ return user ? this.filterFields({ ...user }) : null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+ // Returns user object with all fields
+ _get: async function (clause = {}) {
+ try {
+ const user = await prisma.users.findFirst({ where: clause });
+ return user ? { ...user } : null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.users.count({ where: clause });
+ return count;
+ } catch (error) {
+ console.error(error.message);
+ return 0;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.users.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (clause = {}, limit = null) {
+ try {
+ const users = await prisma.users.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ });
+ return users.map((usr) => this.filterFields(usr));
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ checkPasswordComplexity: function (passwordInput = "") {
+ const passwordComplexity = require("joi-password-complexity");
+ // Can be set via ENV variable on boot. No frontend config at this time.
+ // Docs: https://www.npmjs.com/package/joi-password-complexity
+ const complexityOptions = {
+ min: process.env.PASSWORDMINCHAR || 8,
+ max: process.env.PASSWORDMAXCHAR || 250,
+ lowerCase: process.env.PASSWORDLOWERCASE || 0,
+ upperCase: process.env.PASSWORDUPPERCASE || 0,
+ numeric: process.env.PASSWORDNUMERIC || 0,
+ symbol: process.env.PASSWORDSYMBOL || 0,
+ // reqCount should be equal to how many conditions you are testing for (1-4)
+ requirementCount: process.env.PASSWORDREQUIREMENTS || 0,
+ };
+
+ const complexityCheck = passwordComplexity(
+ complexityOptions,
+ "password"
+ ).validate(passwordInput);
+ if (complexityCheck.hasOwnProperty("error")) {
+ let myError = "";
+ let prepend = "";
+ for (let i = 0; i < complexityCheck.error.details.length; i++) {
+ myError += prepend + complexityCheck.error.details[i].message;
+ prepend = ", ";
+ }
+ return { checkedOK: false, error: myError };
+ }
+
+ return { checkedOK: true, error: "No error." };
+ },
+
+ /**
+ * Check if a user can send a chat based on their daily message limit.
+ * This limit is system wide and not per workspace and only applies to
+ * multi-user mode AND non-admin users.
+ * @param {User} user The user object record.
+ * @returns {Promise} True if the user can send a chat, false otherwise.
+ */
+ canSendChat: async function (user) {
+ const { ROLES } = require("../utils/middleware/multiUserProtected");
+ if (!user || user.dailyMessageLimit === null || user.role === ROLES.admin)
+ return true;
+
+ const { WorkspaceChats } = require("./workspaceChats");
+ const currentChatCount = await WorkspaceChats.count({
+ user_id: user.id,
+ createdAt: {
+ gte: new Date(new Date() - 24 * 60 * 60 * 1000), // 24 hours
+ },
+ });
+
+ return currentChatCount < user.dailyMessageLimit;
+ },
+};
+
+module.exports = { User };
diff --git a/server/models/vectors.js b/server/models/vectors.js
new file mode 100644
index 0000000000000000000000000000000000000000..3653303da24ff31b0b20ae386ac9fc7f446e3826
--- /dev/null
+++ b/server/models/vectors.js
@@ -0,0 +1,79 @@
+const prisma = require("../utils/prisma");
+const { Document } = require("./documents");
+
+const DocumentVectors = {
+ bulkInsert: async function (vectorRecords = []) {
+ if (vectorRecords.length === 0) return;
+
+ try {
+ const inserts = [];
+ vectorRecords.forEach((record) => {
+ inserts.push(
+ prisma.document_vectors.create({
+ data: {
+ docId: record.docId,
+ vectorId: record.vectorId,
+ },
+ })
+ );
+ });
+ await prisma.$transaction(inserts);
+ return { documentsInserted: inserts.length };
+ } catch (error) {
+ console.error("Bulk insert failed", error);
+ return { documentsInserted: 0 };
+ }
+ },
+
+ where: async function (clause = {}, limit) {
+ try {
+ const results = await prisma.document_vectors.findMany({
+ where: clause,
+ take: limit || undefined,
+ });
+ return results;
+ } catch (error) {
+ console.error("Where query failed", error);
+ return [];
+ }
+ },
+
+ deleteForWorkspace: async function (workspaceId) {
+ const documents = await Document.forWorkspace(workspaceId);
+ const docIds = [...new Set(documents.map((doc) => doc.docId))];
+
+ try {
+ await prisma.document_vectors.deleteMany({
+ where: { docId: { in: docIds } },
+ });
+ return true;
+ } catch (error) {
+ console.error("Delete for workspace failed", error);
+ return false;
+ }
+ },
+
+ deleteIds: async function (ids = []) {
+ try {
+ await prisma.document_vectors.deleteMany({
+ where: { id: { in: ids } },
+ });
+ return true;
+ } catch (error) {
+ console.error("Delete IDs failed", error);
+ return false;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.document_vectors.deleteMany({ where: clause });
+ return true;
+ } catch (error) {
+ console.error("Delete failed", error);
+ return false;
+ }
+ },
+};
+
+module.exports = { DocumentVectors };
diff --git a/server/models/welcomeMessages.js b/server/models/welcomeMessages.js
new file mode 100644
index 0000000000000000000000000000000000000000..a24c43c9aa1b8ff5df0342edfc1b839e0a56254d
--- /dev/null
+++ b/server/models/welcomeMessages.js
@@ -0,0 +1,65 @@
+const prisma = require("../utils/prisma");
+
+const WelcomeMessages = {
+ get: async function (clause = {}) {
+ try {
+ const message = await prisma.welcome_messages.findFirst({
+ where: clause,
+ });
+ return message || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (clause = {}, limit) {
+ try {
+ const messages = await prisma.welcome_messages.findMany({
+ where: clause,
+ take: limit || undefined,
+ });
+ return messages;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ saveAll: async function (messages) {
+ try {
+ await prisma.welcome_messages.deleteMany({}); // Delete all existing messages
+
+ // Create new messages
+ // We create each message individually because prisma
+ // with sqlite does not support createMany()
+ for (const [index, message] of messages.entries()) {
+ if (!message.response && !message.user) continue;
+ await prisma.welcome_messages.create({
+ data: {
+ user: message.user,
+ response: message.response,
+ orderIndex: index,
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Failed to save all messages", error.message);
+ }
+ },
+
+ getMessages: async function () {
+ try {
+ const messages = await prisma.welcome_messages.findMany({
+ orderBy: { orderIndex: "asc" },
+ select: { user: true, response: true },
+ });
+ return messages;
+ } catch (error) {
+ console.error("Failed to get all messages", error.message);
+ return [];
+ }
+ },
+};
+
+module.exports.WelcomeMessages = WelcomeMessages;
diff --git a/server/models/workspace.js b/server/models/workspace.js
new file mode 100644
index 0000000000000000000000000000000000000000..6b361d6b49637ecac9a20af497c4f9658bee1391
--- /dev/null
+++ b/server/models/workspace.js
@@ -0,0 +1,606 @@
+const prisma = require("../utils/prisma");
+const slugifyModule = require("slugify");
+const { Document } = require("./documents");
+const { WorkspaceUser } = require("./workspaceUsers");
+const { ROLES } = require("../utils/middleware/multiUserProtected");
+const { v4: uuidv4 } = require("uuid");
+const { User } = require("./user");
+const { PromptHistory } = require("./promptHistory");
+
+function isNullOrNaN(value) {
+ if (value === null) return true;
+ return isNaN(value);
+}
+
+/**
+ * @typedef {Object} Workspace
+ * @property {number} id - The ID of the workspace
+ * @property {string} name - The name of the workspace
+ * @property {string} slug - The slug of the workspace
+ * @property {string} openAiPrompt - The OpenAI prompt of the workspace
+ * @property {string} openAiTemp - The OpenAI temperature of the workspace
+ * @property {number} openAiHistory - The OpenAI history of the workspace
+ * @property {number} similarityThreshold - The similarity threshold of the workspace
+ * @property {string} chatProvider - The chat provider of the workspace
+ * @property {string} chatModel - The chat model of the workspace
+ * @property {number} topN - The top N of the workspace
+ * @property {string} chatMode - The chat mode of the workspace
+ * @property {string} agentProvider - The agent provider of the workspace
+ * @property {string} agentModel - The agent model of the workspace
+ * @property {string} queryRefusalResponse - The query refusal response of the workspace
+ * @property {string} vectorSearchMode - The vector search mode of the workspace
+ */
+
+const Workspace = {
+ defaultPrompt:
+ "Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed.",
+
+ // Used for generic updates so we can validate keys in request body
+ // commented fields are not writable, but are available on the db object
+ writable: [
+ "name",
+ // "slug",
+ // "vectorTag",
+ "openAiTemp",
+ "openAiHistory",
+ "lastUpdatedAt",
+ "openAiPrompt",
+ "similarityThreshold",
+ "chatProvider",
+ "chatModel",
+ "topN",
+ "chatMode",
+ // "pfpFilename",
+ "agentProvider",
+ "agentModel",
+ "queryRefusalResponse",
+ "vectorSearchMode",
+ ],
+
+ validations: {
+ name: (value) => {
+ // If the name is not provided or is not a string then we will use a default name.
+ // as the name field is not nullable in the db schema or has a default value.
+ if (!value || typeof value !== "string") return "My Workspace";
+ return String(value).slice(0, 255);
+ },
+ openAiTemp: (value) => {
+ if (value === null || value === undefined) return null;
+ const temp = parseFloat(value);
+ if (isNullOrNaN(temp) || temp < 0) return null;
+ return temp;
+ },
+ openAiHistory: (value) => {
+ if (value === null || value === undefined) return 20;
+ const history = parseInt(value);
+ if (isNullOrNaN(history)) return 20;
+ if (history < 0) return 0;
+ return history;
+ },
+ similarityThreshold: (value) => {
+ if (value === null || value === undefined) return 0.25;
+ const threshold = parseFloat(value);
+ if (isNullOrNaN(threshold)) return 0.25;
+ if (threshold < 0) return 0.0;
+ if (threshold > 1) return 1.0;
+ return threshold;
+ },
+ topN: (value) => {
+ if (value === null || value === undefined) return 4;
+ const n = parseInt(value);
+ if (isNullOrNaN(n)) return 4;
+ if (n < 1) return 1;
+ return n;
+ },
+ chatMode: (value) => {
+ if (!value || !["chat", "query"].includes(value)) return "chat";
+ return value;
+ },
+ chatProvider: (value) => {
+ if (!value || typeof value !== "string" || value === "none") return null;
+ return String(value);
+ },
+ chatModel: (value) => {
+ if (!value || typeof value !== "string") return null;
+ return String(value);
+ },
+ agentProvider: (value) => {
+ if (!value || typeof value !== "string" || value === "none") return null;
+ return String(value);
+ },
+ agentModel: (value) => {
+ if (!value || typeof value !== "string") return null;
+ return String(value);
+ },
+ queryRefusalResponse: (value) => {
+ if (!value || typeof value !== "string") return null;
+ return String(value);
+ },
+ openAiPrompt: (value) => {
+ if (!value || typeof value !== "string") return null;
+ return String(value);
+ },
+ vectorSearchMode: (value) => {
+ if (
+ !value ||
+ typeof value !== "string" ||
+ !["default", "rerank"].includes(value)
+ )
+ return "default";
+ return value;
+ },
+ },
+
+ /**
+ * The default Slugify module requires some additional mapping to prevent downstream issues
+ * with some vector db providers and instead of building a normalization method for every provider
+ * we can capture this on the table level to not have to worry about it.
+ * @param {...any} args - slugify args for npm package.
+ * @returns {string}
+ */
+ slugify: function (...args) {
+ slugifyModule.extend({
+ "+": " plus ",
+ "!": " bang ",
+ "@": " at ",
+ "*": " splat ",
+ ".": " dot ",
+ ":": "",
+ "~": "",
+ "(": "",
+ ")": "",
+ "'": "",
+ '"': "",
+ "|": "",
+ });
+ return slugifyModule(...args);
+ },
+
+ /**
+ * Validate the fields for a workspace update.
+ * @param {Object} updates - The updates to validate - should be writable fields
+ * @returns {Object} The validated updates. Only valid fields are returned.
+ */
+ validateFields: function (updates = {}) {
+ const validatedFields = {};
+ for (const [key, value] of Object.entries(updates)) {
+ if (!this.writable.includes(key)) continue;
+ if (this.validations[key]) {
+ validatedFields[key] = this.validations[key](value);
+ } else {
+ // If there is no validation for the field then we will just pass it through.
+ validatedFields[key] = value;
+ }
+ }
+ return validatedFields;
+ },
+
+ /**
+ * Create a new workspace.
+ * @param {string} name - The name of the workspace.
+ * @param {number} creatorId - The ID of the user creating the workspace.
+ * @param {Object} additionalFields - Additional fields to apply to the workspace - will be validated.
+ * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the created workspace and an error message if applicable.
+ */
+ new: async function (name = null, creatorId = null, additionalFields = {}) {
+ if (!name) return { workspace: null, message: "name cannot be null" };
+ var slug = this.slugify(name, { lower: true });
+ slug = slug || uuidv4();
+
+ const existingBySlug = await this.get({ slug });
+ if (existingBySlug !== null) {
+ const slugSeed = Math.floor(10000000 + Math.random() * 90000000);
+ slug = this.slugify(`${name}-${slugSeed}`, { lower: true });
+ }
+
+ try {
+ const workspace = await prisma.workspaces.create({
+ data: {
+ name: this.validations.name(name),
+ ...this.validateFields(additionalFields),
+ slug,
+ },
+ });
+
+ // If created with a user then we need to create the relationship as well.
+ // If creating with an admin User it wont change anything because admins can
+ // view all workspaces anyway.
+ if (!!creatorId) await WorkspaceUser.create(creatorId, workspace.id);
+ return { workspace, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { workspace: null, message: error.message };
+ }
+ },
+
+ /**
+ * Update the settings for a workspace. Applies validations to the updates provided.
+ * @param {number} id - The ID of the workspace to update.
+ * @param {Object} updates - The data to update.
+ * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the updated workspace and an error message if applicable.
+ */
+ update: async function (id = null, updates = {}) {
+ if (!id) throw new Error("No workspace id provided for update");
+
+ const validatedUpdates = this.validateFields(updates);
+ if (Object.keys(validatedUpdates).length === 0)
+ return { workspace: { id }, message: "No valid fields to update!" };
+
+ // If the user unset the chatProvider we will need
+ // to then clear the chatModel as well to prevent confusion during
+ // LLM loading.
+ if (validatedUpdates?.chatProvider === "default") {
+ validatedUpdates.chatProvider = null;
+ validatedUpdates.chatModel = null;
+ }
+
+ return this._update(id, validatedUpdates);
+ },
+
+ /**
+ * Direct update of workspace settings without any validation.
+ * @param {number} id - The ID of the workspace to update.
+ * @param {Object} data - The data to update.
+ * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the updated workspace and an error message if applicable.
+ */
+ _update: async function (id = null, data = {}) {
+ if (!id) throw new Error("No workspace id provided for update");
+
+ try {
+ const workspace = await prisma.workspaces.update({
+ where: { id },
+ data,
+ });
+ return { workspace, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { workspace: null, message: error.message };
+ }
+ },
+
+ getWithUser: async function (user = null, clause = {}) {
+ if ([ROLES.admin, ROLES.manager].includes(user.role))
+ return this.get(clause);
+
+ try {
+ const workspace = await prisma.workspaces.findFirst({
+ where: {
+ ...clause,
+ workspace_users: {
+ some: {
+ user_id: user?.id,
+ },
+ },
+ },
+ include: {
+ workspace_users: true,
+ documents: true,
+ },
+ });
+
+ if (!workspace) return null;
+
+ return {
+ ...workspace,
+ documents: await Document.forWorkspace(workspace.id),
+ contextWindow: this._getContextWindow(workspace),
+ currentContextTokenCount: await this._getCurrentContextTokenCount(
+ workspace.id
+ ),
+ };
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ /**
+ * Get the total token count of all parsed files in a workspace/thread
+ * @param {number} workspaceId - The ID of the workspace
+ * @param {number|null} threadId - Optional thread ID to filter by
+ * @returns {Promise} Total token count of all files
+ * @private
+ */
+ async _getCurrentContextTokenCount(workspaceId, threadId = null) {
+ const { WorkspaceParsedFiles } = require("./workspaceParsedFiles");
+ return await WorkspaceParsedFiles.totalTokenCount({
+ workspaceId: Number(workspaceId),
+ threadId: threadId ? Number(threadId) : null,
+ });
+ },
+
+ /**
+ * Get the context window size for a workspace based on its provider and model settings.
+ * If the workspace has no provider/model set, falls back to system defaults.
+ * @param {Workspace} workspace - The workspace to get context window for
+ * @returns {number|null} The context window size in tokens (defaults to null if no provider/model found)
+ * @private
+ */
+ _getContextWindow: function (workspace) {
+ const {
+ getLLMProviderClass,
+ getBaseLLMProviderModel,
+ } = require("../utils/helpers");
+ const provider = workspace.chatProvider || process.env.LLM_PROVIDER || null;
+ const LLMProvider = getLLMProviderClass({ provider });
+ const model =
+ workspace.chatModel || getBaseLLMProviderModel({ provider }) || null;
+
+ if (!provider || !model) return null;
+ return LLMProvider?.promptWindowLimit?.(model) || null;
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const workspace = await prisma.workspaces.findFirst({
+ where: clause,
+ include: {
+ documents: true,
+ },
+ });
+
+ if (!workspace) return null;
+ return {
+ ...workspace,
+ contextWindow: this._getContextWindow(workspace),
+ currentContextTokenCount: await this._getCurrentContextTokenCount(
+ workspace.id
+ ),
+ };
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.workspaces.delete({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const results = await prisma.workspaces.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ whereWithUser: async function (
+ user,
+ clause = {},
+ limit = null,
+ orderBy = null
+ ) {
+ if ([ROLES.admin, ROLES.manager].includes(user.role))
+ return await this.where(clause, limit, orderBy);
+
+ try {
+ const workspaces = await prisma.workspaces.findMany({
+ where: {
+ ...clause,
+ workspace_users: {
+ some: {
+ user_id: user.id,
+ },
+ },
+ },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return workspaces;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ whereWithUsers: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const workspaces = await this.where(clause, limit, orderBy);
+ for (const workspace of workspaces) {
+ const userIds = (
+ await WorkspaceUser.where({ workspace_id: Number(workspace.id) })
+ ).map((rel) => rel.user_id);
+ workspace.userIds = userIds;
+ }
+ return workspaces;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ /**
+ * Get all users for a workspace.
+ * @param {number} workspaceId - The ID of the workspace to get users for.
+ * @returns {Promise>} A promise that resolves to an array of user objects.
+ */
+ workspaceUsers: async function (workspaceId) {
+ try {
+ const users = (
+ await WorkspaceUser.where({ workspace_id: Number(workspaceId) })
+ ).map((rel) => rel);
+
+ const usersById = await User.where({
+ id: { in: users.map((user) => user.user_id) },
+ });
+
+ const userInfo = usersById.map((user) => {
+ const workspaceUser = users.find((u) => u.user_id === user.id);
+ return {
+ userId: user.id,
+ username: user.username,
+ role: user.role,
+ lastUpdatedAt: workspaceUser.lastUpdatedAt,
+ };
+ });
+
+ return userInfo;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ /**
+ * Update the users for a workspace. Will remove all existing users and replace them with the new list.
+ * @param {number} workspaceId - The ID of the workspace to update.
+ * @param {number[]} userIds - An array of user IDs to add to the workspace.
+ * @returns {Promise<{success: boolean, error: string | null}>} A promise that resolves to an object containing the success status and an error message if applicable.
+ */
+ updateUsers: async function (workspaceId, userIds = []) {
+ try {
+ await WorkspaceUser.delete({ workspace_id: Number(workspaceId) });
+ await WorkspaceUser.createManyUsers(userIds, workspaceId);
+ return { success: true, error: null };
+ } catch (error) {
+ console.error(error.message);
+ return { success: false, error: error.message };
+ }
+ },
+
+ trackChange: async function (prevData, newData, user) {
+ try {
+ await this._trackWorkspacePromptChange(prevData, newData, user);
+ return;
+ } catch (error) {
+ console.error("Error tracking workspace change:", error.message);
+ return;
+ }
+ },
+
+ /**
+ * We are tracking this change to determine the need to a prompt library or
+ * prompt assistant feature. If this is something you would like to see - tell us on GitHub!
+ * We now track the prompt change in the PromptHistory model.
+ * which is a sub-model of the Workspace model.
+ * @param {Workspace} prevData - The previous data of the workspace.
+ * @param {Workspace} newData - The new data of the workspace.
+ * @param {{id: number, role: string}|null} user - The user who made the change.
+ * @returns {Promise}
+ */
+ _trackWorkspacePromptChange: async function (prevData, newData, user = null) {
+ if (
+ !!newData?.openAiPrompt && // new prompt is set
+ !!prevData?.openAiPrompt && // previous prompt was not null (default)
+ prevData?.openAiPrompt !== this.defaultPrompt && // previous prompt was not default
+ newData?.openAiPrompt !== prevData?.openAiPrompt // previous and new prompt are not the same
+ )
+ await PromptHistory.handlePromptChange(prevData, user); // log the change to the prompt history
+
+ const { Telemetry } = require("./telemetry");
+ const { EventLogs } = require("./eventLogs");
+ if (
+ !newData?.openAiPrompt || // no prompt change
+ newData?.openAiPrompt === this.defaultPrompt || // new prompt is default prompt
+ newData?.openAiPrompt === prevData?.openAiPrompt // same prompt
+ )
+ return;
+
+ await Telemetry.sendTelemetry("workspace_prompt_changed");
+ await EventLogs.logEvent(
+ "workspace_prompt_changed",
+ {
+ workspaceName: prevData?.name,
+ prevSystemPrompt: prevData?.openAiPrompt || this.defaultPrompt,
+ newSystemPrompt: newData?.openAiPrompt,
+ },
+ user?.id
+ );
+ return;
+ },
+
+ // Direct DB queries for API use only.
+ /**
+ * Generic prisma FindMany query for workspaces collections
+ * @param {import("../node_modules/.prisma/client/index.d.ts").Prisma.TypeMap['model']['workspaces']['operations']['findMany']['args']} prismaQuery
+ * @returns
+ */
+ _findMany: async function (prismaQuery = {}) {
+ try {
+ const results = await prisma.workspaces.findMany(prismaQuery);
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ /**
+ * Generic prisma query for .get of workspaces collections
+ * @param {import("../node_modules/.prisma/client/index.d.ts").Prisma.TypeMap['model']['workspaces']['operations']['findFirst']['args']} prismaQuery
+ * @returns
+ */
+ _findFirst: async function (prismaQuery = {}) {
+ try {
+ const results = await prisma.workspaces.findFirst(prismaQuery);
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ /**
+ * Get the prompt history for a workspace.
+ * @param {Object} options - The options to get prompt history for.
+ * @param {number} options.workspaceId - The ID of the workspace to get prompt history for.
+ * @returns {Promise>} A promise that resolves to an array of prompt history objects.
+ */
+ promptHistory: async function ({ workspaceId }) {
+ try {
+ const results = await PromptHistory.forWorkspace(workspaceId);
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ /**
+ * Delete the prompt history for a workspace.
+ * @param {Object} options - The options to delete the prompt history for.
+ * @param {number} options.workspaceId - The ID of the workspace to delete prompt history for.
+ * @returns {Promise} A promise that resolves to a boolean indicating the success of the operation.
+ */
+ deleteAllPromptHistory: async function ({ workspaceId }) {
+ try {
+ return await PromptHistory.delete({ workspaceId });
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ /**
+ * Delete the prompt history for a workspace.
+ * @param {Object} options - The options to delete the prompt history for.
+ * @param {number} options.workspaceId - The ID of the workspace to delete prompt history for.
+ * @param {number} options.id - The ID of the prompt history to delete.
+ * @returns {Promise} A promise that resolves to a boolean indicating the success of the operation.
+ */
+ deletePromptHistory: async function ({ workspaceId, id }) {
+ try {
+ return await PromptHistory.delete({ id, workspaceId });
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+};
+
+module.exports = { Workspace };
diff --git a/server/models/workspaceAgentInvocation.js b/server/models/workspaceAgentInvocation.js
new file mode 100644
index 0000000000000000000000000000000000000000..b00833754b32c328b77f7017fe698eec4ee890cd
--- /dev/null
+++ b/server/models/workspaceAgentInvocation.js
@@ -0,0 +1,97 @@
+const prisma = require("../utils/prisma");
+const { v4: uuidv4 } = require("uuid");
+
+const WorkspaceAgentInvocation = {
+ // returns array of strings with their @ handle.
+ // must start with @agent for now.
+ parseAgents: function (promptString) {
+ if (!promptString.startsWith("@agent")) return [];
+ return promptString.split(/\s+/).filter((v) => v.startsWith("@"));
+ },
+
+ close: async function (uuid) {
+ if (!uuid) return;
+ try {
+ await prisma.workspace_agent_invocations.update({
+ where: { uuid: String(uuid) },
+ data: { closed: true },
+ });
+ } catch {}
+ },
+
+ new: async function ({ prompt, workspace, user = null, thread = null }) {
+ try {
+ const invocation = await prisma.workspace_agent_invocations.create({
+ data: {
+ uuid: uuidv4(),
+ workspace_id: workspace.id,
+ prompt: String(prompt),
+ user_id: user?.id,
+ thread_id: thread?.id,
+ },
+ });
+
+ return { invocation, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { invocation: null, message: error.message };
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const invocation = await prisma.workspace_agent_invocations.findFirst({
+ where: clause,
+ });
+
+ return invocation || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ getWithWorkspace: async function (clause = {}) {
+ try {
+ const invocation = await prisma.workspace_agent_invocations.findFirst({
+ where: clause,
+ include: {
+ workspace: true,
+ },
+ });
+
+ return invocation || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.workspace_agent_invocations.delete({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const results = await prisma.workspace_agent_invocations.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+};
+
+module.exports = { WorkspaceAgentInvocation };
diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js
new file mode 100644
index 0000000000000000000000000000000000000000..e48807be71dd3f890ac31f606265439056a5932f
--- /dev/null
+++ b/server/models/workspaceChats.js
@@ -0,0 +1,320 @@
+const prisma = require("../utils/prisma");
+const { safeJSONStringify } = require("../utils/helpers/chat/responses");
+
+const WorkspaceChats = {
+ new: async function ({
+ workspaceId,
+ prompt,
+ response = {},
+ user = null,
+ threadId = null,
+ include = true,
+ apiSessionId = null,
+ }) {
+ try {
+ const chat = await prisma.workspace_chats.create({
+ data: {
+ workspaceId,
+ prompt,
+ response: safeJSONStringify(response),
+ user_id: user?.id || null,
+ thread_id: threadId,
+ api_session_id: apiSessionId,
+ include,
+ },
+ });
+ return { chat, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { chat: null, message: error.message };
+ }
+ },
+
+ forWorkspaceByUser: async function (
+ workspaceId = null,
+ userId = null,
+ limit = null,
+ orderBy = null
+ ) {
+ if (!workspaceId || !userId) return [];
+ try {
+ const chats = await prisma.workspace_chats.findMany({
+ where: {
+ workspaceId,
+ user_id: userId,
+ thread_id: null, // this function is now only used for the default thread on workspaces and users
+ api_session_id: null, // do not include api-session chats in the frontend for anyone.
+ include: true,
+ },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }),
+ });
+ return chats;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ forWorkspaceByApiSessionId: async function (
+ workspaceId = null,
+ apiSessionId = null,
+ limit = null,
+ orderBy = null
+ ) {
+ if (!workspaceId || !apiSessionId) return [];
+ try {
+ const chats = await prisma.workspace_chats.findMany({
+ where: {
+ workspaceId,
+ user_id: null,
+ api_session_id: String(apiSessionId),
+ thread_id: null,
+ },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }),
+ });
+ return chats;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ forWorkspace: async function (
+ workspaceId = null,
+ limit = null,
+ orderBy = null
+ ) {
+ if (!workspaceId) return [];
+ try {
+ const chats = await prisma.workspace_chats.findMany({
+ where: {
+ workspaceId,
+ thread_id: null, // this function is now only used for the default thread on workspaces
+ api_session_id: null, // do not include api-session chats in the frontend for anyone.
+ include: true,
+ },
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }),
+ });
+ return chats;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ /**
+ * @deprecated Use markThreadHistoryInvalidV2 instead.
+ */
+ markHistoryInvalid: async function (workspaceId = null, user = null) {
+ if (!workspaceId) return;
+ try {
+ await prisma.workspace_chats.updateMany({
+ where: {
+ workspaceId,
+ user_id: user?.id,
+ thread_id: null, // this function is now only used for the default thread on workspaces
+ },
+ data: {
+ include: false,
+ },
+ });
+ return;
+ } catch (error) {
+ console.error(error.message);
+ }
+ },
+
+ /**
+ * @deprecated Use markThreadHistoryInvalidV2 instead.
+ */
+ markThreadHistoryInvalid: async function (
+ workspaceId = null,
+ user = null,
+ threadId = null
+ ) {
+ if (!workspaceId || !threadId) return;
+ try {
+ await prisma.workspace_chats.updateMany({
+ where: {
+ workspaceId,
+ thread_id: threadId,
+ user_id: user?.id,
+ },
+ data: {
+ include: false,
+ },
+ });
+ return;
+ } catch (error) {
+ console.error(error.message);
+ }
+ },
+
+ /**
+ * @description This function is used to mark a thread's history as invalid.
+ * and works with an arbitrary where clause.
+ * @param {Object} whereClause - The where clause to update the chats.
+ * @param {Object} data - The data to update the chats with.
+ * @returns {Promise}
+ */
+ markThreadHistoryInvalidV2: async function (whereClause = {}) {
+ if (!whereClause) return;
+ try {
+ await prisma.workspace_chats.updateMany({
+ where: whereClause,
+ data: {
+ include: false,
+ },
+ });
+ return;
+ } catch (error) {
+ console.error(error.message);
+ }
+ },
+
+ get: async function (clause = {}, limit = null, orderBy = null) {
+ try {
+ const chat = await prisma.workspace_chats.findFirst({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return chat || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.workspace_chats.deleteMany({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ offset = null
+ ) {
+ try {
+ const chats = await prisma.workspace_chats.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(offset !== null ? { skip: offset } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ });
+ return chats;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.workspace_chats.count({
+ where: clause,
+ });
+ return count;
+ } catch (error) {
+ console.error(error.message);
+ return 0;
+ }
+ },
+
+ whereWithData: async function (
+ clause = {},
+ limit = null,
+ offset = null,
+ orderBy = null
+ ) {
+ const { Workspace } = require("./workspace");
+ const { User } = require("./user");
+
+ try {
+ const results = await this.where(clause, limit, orderBy, offset);
+
+ for (const res of results) {
+ const workspace = await Workspace.get({ id: res.workspaceId });
+ res.workspace = workspace
+ ? { name: workspace.name, slug: workspace.slug }
+ : { name: "deleted workspace", slug: null };
+
+ const user = res.user_id ? await User.get({ id: res.user_id }) : null;
+ res.user = user
+ ? { username: user.username }
+ : { username: res.api_session_id !== null ? "API" : "unknown user" };
+ }
+
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+ updateFeedbackScore: async function (chatId = null, feedbackScore = null) {
+ if (!chatId) return;
+ try {
+ await prisma.workspace_chats.update({
+ where: {
+ id: Number(chatId),
+ },
+ data: {
+ feedbackScore:
+ feedbackScore === null ? null : Number(feedbackScore) === 1,
+ },
+ });
+ return;
+ } catch (error) {
+ console.error(error.message);
+ }
+ },
+
+ // Explicit update of settings + key validations.
+ // Only use this method when directly setting a key value
+ // that takes no user input for the keys being modified.
+ _update: async function (id = null, data = {}) {
+ if (!id) throw new Error("No workspace chat id provided for update");
+
+ try {
+ await prisma.workspace_chats.update({
+ where: { id },
+ data,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+ bulkCreate: async function (chatsData) {
+ // TODO: Replace with createMany when we update prisma to latest version
+ // The version of prisma that we are currently using does not support createMany with SQLite
+ try {
+ const createdChats = [];
+ for (const chatData of chatsData) {
+ const chat = await prisma.workspace_chats.create({
+ data: chatData,
+ });
+ createdChats.push(chat);
+ }
+ return { chats: createdChats, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { chats: null, message: error.message };
+ }
+ },
+};
+
+module.exports = { WorkspaceChats };
diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js
new file mode 100644
index 0000000000000000000000000000000000000000..37a538b9d72a90ffe2d410c75c73f238b639152d
--- /dev/null
+++ b/server/models/workspaceParsedFiles.js
@@ -0,0 +1,230 @@
+const prisma = require("../utils/prisma");
+const { EventLogs } = require("./eventLogs");
+const { Document } = require("./documents");
+const { documentsPath, directUploadsPath } = require("../utils/files");
+const { safeJsonParse } = require("../utils/http");
+const fs = require("fs");
+const path = require("path");
+
+const WorkspaceParsedFiles = {
+ create: async function ({
+ filename,
+ workspaceId,
+ userId = null,
+ threadId = null,
+ metadata = null,
+ tokenCountEstimate = 0,
+ }) {
+ try {
+ const file = await prisma.workspace_parsed_files.create({
+ data: {
+ filename,
+ workspaceId: parseInt(workspaceId),
+ userId: userId ? parseInt(userId) : null,
+ threadId: threadId ? parseInt(threadId) : null,
+ metadata,
+ tokenCountEstimate,
+ },
+ });
+
+ await EventLogs.logEvent(
+ "workspace_file_uploaded",
+ {
+ filename,
+ workspaceId,
+ },
+ userId
+ );
+
+ return { file, error: null };
+ } catch (error) {
+ console.error("FAILED TO CREATE PARSED FILE RECORD.", error.message);
+ return { file: null, error: error.message };
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const file = await prisma.workspace_parsed_files.findFirst({
+ where: clause,
+ });
+ return file;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ select = null
+ ) {
+ try {
+ const files = await prisma.workspace_parsed_files.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ ...(select !== null ? { select } : {}),
+ });
+ return files;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.workspace_parsed_files.deleteMany({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ totalTokenCount: async function (clause = {}) {
+ const { _sum } = await prisma.workspace_parsed_files.aggregate({
+ where: clause,
+ _sum: { tokenCountEstimate: true },
+ });
+ return _sum.tokenCountEstimate || 0;
+ },
+
+ moveToDocumentsAndEmbed: async function (fileId, workspace) {
+ try {
+ const parsedFile = await this.get({ id: parseInt(fileId) });
+ if (!parsedFile) throw new Error("File not found");
+
+ // Get file location from metadata
+ const metadata = safeJsonParse(parsedFile.metadata, {});
+ const location = metadata.location;
+ if (!location) throw new Error("No file location in metadata");
+
+ // Get file from metadata location
+ const sourceFile = path.join(directUploadsPath, path.basename(location));
+ if (!fs.existsSync(sourceFile)) throw new Error("Source file not found");
+
+ // Move to custom-documents
+ const customDocsPath = path.join(documentsPath, "custom-documents");
+ if (!fs.existsSync(customDocsPath))
+ fs.mkdirSync(customDocsPath, { recursive: true });
+
+ // Copy the file to custom-documents
+ const targetPath = path.join(customDocsPath, path.basename(location));
+ fs.copyFileSync(sourceFile, targetPath);
+ fs.unlinkSync(sourceFile);
+
+ const {
+ failedToEmbed = [],
+ errors = [],
+ embedded = [],
+ } = await Document.addDocuments(
+ workspace,
+ [`custom-documents/${path.basename(location)}`],
+ parsedFile.userId
+ );
+
+ if (failedToEmbed.length > 0)
+ throw new Error(errors[0] || "Failed to embed document");
+
+ const document = await Document.get({
+ workspaceId: workspace.id,
+ docpath: embedded[0],
+ });
+ return { success: true, error: null, document };
+ } catch (error) {
+ console.error("Failed to move and embed file:", error);
+ return { success: false, error: error.message, document: null };
+ } finally {
+ // Always delete the file after processing
+ await this.delete({ id: parseInt(fileId) });
+ }
+ },
+
+ getContextMetadataAndLimits: async function (
+ workspace,
+ thread = null,
+ user = null
+ ) {
+ try {
+ if (!workspace) throw new Error("Workspace is required");
+ const files = await this.where({
+ workspaceId: workspace.id,
+ threadId: thread?.id || null,
+ ...(user ? { userId: user.id } : {}),
+ });
+
+ const results = [];
+ let totalTokens = 0;
+
+ for (const file of files) {
+ const metadata = safeJsonParse(file.metadata, {});
+ totalTokens += file.tokenCountEstimate || 0;
+ results.push({
+ id: file.id,
+ title: metadata.title || metadata.location,
+ location: metadata.location,
+ token_count_estimate: file.tokenCountEstimate,
+ });
+ }
+
+ return {
+ files: results,
+ contextWindow: workspace.contextWindow,
+ currentContextTokenCount: totalTokens,
+ };
+ } catch (error) {
+ console.error("Failed to get context metadata:", error);
+ return {
+ files: [],
+ contextWindow: Infinity,
+ currentContextTokenCount: 0,
+ };
+ }
+ },
+
+ getContextFiles: async function (workspace, thread = null, user = null) {
+ try {
+ const files = await this.where({
+ workspaceId: workspace.id,
+ threadId: thread?.id || null,
+ ...(user ? { userId: user.id } : {}),
+ });
+
+ const results = [];
+ for (const file of files) {
+ const metadata = safeJsonParse(file.metadata, {});
+ const location = metadata.location;
+ if (!location) continue;
+
+ const sourceFile = path.join(
+ directUploadsPath,
+ path.basename(location)
+ );
+ if (!fs.existsSync(sourceFile)) continue;
+
+ const content = fs.readFileSync(sourceFile, "utf-8");
+ const data = safeJsonParse(content, null);
+ if (!data?.pageContent) continue;
+
+ results.push({
+ pageContent: data.pageContent,
+ token_count_estimate: file.tokenCountEstimate,
+ ...metadata,
+ });
+ }
+
+ return results;
+ } catch (error) {
+ console.error("Failed to get context files:", error);
+ return [];
+ }
+ },
+};
+
+module.exports = { WorkspaceParsedFiles };
diff --git a/server/models/workspaceThread.js b/server/models/workspaceThread.js
new file mode 100644
index 0000000000000000000000000000000000000000..58e895a1f32bceebf351de29ccea91a5a33b3ea9
--- /dev/null
+++ b/server/models/workspaceThread.js
@@ -0,0 +1,150 @@
+const prisma = require("../utils/prisma");
+const slugifyModule = require("slugify");
+const { v4: uuidv4 } = require("uuid");
+
+const WorkspaceThread = {
+ defaultName: "Thread",
+ writable: ["name"],
+
+ /**
+ * The default Slugify module requires some additional mapping to prevent downstream issues
+ * if the user is able to define a slug externally. We have to block non-escapable URL chars
+ * so that is the slug is rendered it doesn't break the URL or UI when visited.
+ * @param {...any} args - slugify args for npm package.
+ * @returns {string}
+ */
+ slugify: function (...args) {
+ slugifyModule.extend({
+ "+": " plus ",
+ "!": " bang ",
+ "@": " at ",
+ "*": " splat ",
+ ".": " dot ",
+ ":": "",
+ "~": "",
+ "(": "",
+ ")": "",
+ "'": "",
+ '"': "",
+ "|": "",
+ });
+ return slugifyModule(...args);
+ },
+
+ new: async function (workspace, userId = null, data = {}) {
+ try {
+ const thread = await prisma.workspace_threads.create({
+ data: {
+ name: data.name ? String(data.name) : this.defaultName,
+ slug: data.slug
+ ? this.slugify(data.slug, { lowercase: true })
+ : uuidv4(),
+ user_id: userId ? Number(userId) : null,
+ workspace_id: workspace.id,
+ },
+ });
+
+ return { thread, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { thread: null, message: error.message };
+ }
+ },
+
+ update: async function (prevThread = null, data = {}) {
+ if (!prevThread) throw new Error("No thread id provided for update");
+
+ const validData = {};
+ Object.entries(data).forEach(([key, value]) => {
+ if (!this.writable.includes(key)) return;
+ validData[key] = value;
+ });
+
+ if (Object.keys(validData).length === 0)
+ return { thread: prevThread, message: "No valid fields to update!" };
+
+ try {
+ const thread = await prisma.workspace_threads.update({
+ where: { id: prevThread.id },
+ data: validData,
+ });
+ return { thread, message: null };
+ } catch (error) {
+ console.error(error.message);
+ return { thread: null, message: error.message };
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const thread = await prisma.workspace_threads.findFirst({
+ where: clause,
+ });
+
+ return thread || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.workspace_threads.deleteMany({
+ where: clause,
+ });
+ return true;
+ } catch (error) {
+ console.error(error.message);
+ return false;
+ }
+ },
+
+ where: async function (
+ clause = {},
+ limit = null,
+ orderBy = null,
+ include = null
+ ) {
+ try {
+ const results = await prisma.workspace_threads.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ ...(orderBy !== null ? { orderBy } : {}),
+ ...(include !== null ? { include } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ // Will fire on first message (included or not) for a thread and rename the thread with the newName prop.
+ autoRenameThread: async function ({
+ workspace = null,
+ thread = null,
+ user = null,
+ newName = null,
+ onRename = null,
+ }) {
+ if (!workspace || !thread || !newName) return false;
+ if (thread.name !== this.defaultName) return false; // don't rename if already named.
+
+ const { WorkspaceChats } = require("./workspaceChats");
+ const chatCount = await WorkspaceChats.count({
+ workspaceId: workspace.id,
+ user_id: user?.id || null,
+ thread_id: thread.id,
+ });
+ if (chatCount !== 1) return { renamed: false, thread };
+ const { thread: updatedThread } = await this.update(thread, {
+ name: newName,
+ });
+
+ onRename?.(updatedThread);
+ return true;
+ },
+};
+
+module.exports = { WorkspaceThread };
diff --git a/server/models/workspaceUsers.js b/server/models/workspaceUsers.js
new file mode 100644
index 0000000000000000000000000000000000000000..c27dc858a070e9047ca7d69f4547cf91a212a7a0
--- /dev/null
+++ b/server/models/workspaceUsers.js
@@ -0,0 +1,103 @@
+const prisma = require("../utils/prisma");
+
+const WorkspaceUser = {
+ createMany: async function (userId, workspaceIds = []) {
+ if (workspaceIds.length === 0) return;
+ try {
+ await prisma.$transaction(
+ workspaceIds.map((workspaceId) =>
+ prisma.workspace_users.create({
+ data: { user_id: userId, workspace_id: workspaceId },
+ })
+ )
+ );
+ } catch (error) {
+ console.error(error.message);
+ }
+ return;
+ },
+
+ /**
+ * Create many workspace users.
+ * @param {Array} userIds - An array of user IDs to create workspace users for.
+ * @param {number} workspaceId - The ID of the workspace to create workspace users for.
+ * @returns {Promise} A promise that resolves when the workspace users are created.
+ */
+ createManyUsers: async function (userIds = [], workspaceId) {
+ if (userIds.length === 0) return;
+ try {
+ await prisma.$transaction(
+ userIds.map((userId) =>
+ prisma.workspace_users.create({
+ data: {
+ user_id: Number(userId),
+ workspace_id: Number(workspaceId),
+ },
+ })
+ )
+ );
+ } catch (error) {
+ console.error(error.message);
+ }
+ return;
+ },
+
+ create: async function (userId = 0, workspaceId = 0) {
+ try {
+ await prisma.workspace_users.create({
+ data: { user_id: Number(userId), workspace_id: Number(workspaceId) },
+ });
+ return true;
+ } catch (error) {
+ console.error(
+ "FAILED TO CREATE WORKSPACE_USER RELATIONSHIP.",
+ error.message
+ );
+ return false;
+ }
+ },
+
+ get: async function (clause = {}) {
+ try {
+ const result = await prisma.workspace_users.findFirst({ where: clause });
+ return result || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (clause = {}, limit = null) {
+ try {
+ const results = await prisma.workspace_users.findMany({
+ where: clause,
+ ...(limit !== null ? { take: limit } : {}),
+ });
+ return results;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ count: async function (clause = {}) {
+ try {
+ const count = await prisma.workspace_users.count({ where: clause });
+ return count;
+ } catch (error) {
+ console.error(error.message);
+ return 0;
+ }
+ },
+
+ delete: async function (clause = {}) {
+ try {
+ await prisma.workspace_users.deleteMany({ where: clause });
+ } catch (error) {
+ console.error(error.message);
+ }
+ return;
+ },
+};
+
+module.exports.WorkspaceUser = WorkspaceUser;
diff --git a/server/models/workspacesSuggestedMessages.js b/server/models/workspacesSuggestedMessages.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef35a5bb5af9517491cc5f74e9930a76f49bf1a9
--- /dev/null
+++ b/server/models/workspacesSuggestedMessages.js
@@ -0,0 +1,83 @@
+const prisma = require("../utils/prisma");
+
+const WorkspaceSuggestedMessages = {
+ get: async function (clause = {}) {
+ try {
+ const message = await prisma.workspace_suggested_messages.findFirst({
+ where: clause,
+ });
+ return message || null;
+ } catch (error) {
+ console.error(error.message);
+ return null;
+ }
+ },
+
+ where: async function (clause = {}, limit) {
+ try {
+ const messages = await prisma.workspace_suggested_messages.findMany({
+ where: clause,
+ take: limit || undefined,
+ });
+ return messages;
+ } catch (error) {
+ console.error(error.message);
+ return [];
+ }
+ },
+
+ saveAll: async function (messages, workspaceSlug) {
+ try {
+ const workspace = await prisma.workspaces.findUnique({
+ where: { slug: workspaceSlug },
+ });
+
+ if (!workspace) throw new Error("Workspace not found");
+
+ // Delete all existing messages for the workspace
+ await prisma.workspace_suggested_messages.deleteMany({
+ where: { workspaceId: workspace.id },
+ });
+
+ // Create new messages
+ // We create each message individually because prisma
+ // with sqlite does not support createMany()
+ for (const message of messages) {
+ await prisma.workspace_suggested_messages.create({
+ data: {
+ workspaceId: workspace.id,
+ heading: message.heading,
+ message: message.message,
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Failed to save all messages", error.message);
+ }
+ },
+
+ getMessages: async function (workspaceSlug) {
+ try {
+ const workspace = await prisma.workspaces.findUnique({
+ where: { slug: workspaceSlug },
+ });
+
+ if (!workspace) throw new Error("Workspace not found");
+
+ const messages = await prisma.workspace_suggested_messages.findMany({
+ where: { workspaceId: workspace.id },
+ orderBy: { createdAt: "asc" },
+ });
+
+ return messages.map((msg) => ({
+ heading: msg.heading,
+ message: msg.message,
+ }));
+ } catch (error) {
+ console.error("Failed to get all messages", error.message);
+ return [];
+ }
+ },
+};
+
+module.exports.WorkspaceSuggestedMessages = WorkspaceSuggestedMessages;
diff --git a/server/nodemon.json b/server/nodemon.json
new file mode 100644
index 0000000000000000000000000000000000000000..d778fe53bd3ae80bf1a7379ab980c7a2a10a74d7
--- /dev/null
+++ b/server/nodemon.json
@@ -0,0 +1,6 @@
+{
+ "events": {
+ "start": "yarn swagger",
+ "restart": "yarn swagger"
+ }
+}
\ No newline at end of file
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..ee029d4b508d1f81c50edcda470d6c70d2ecc29e
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,104 @@
+{
+ "name": "anything-llm-server",
+ "version": "1.8.5",
+ "description": "Server endpoints to process or create content for chatting",
+ "main": "index.js",
+ "author": "Timothy Carambat (Mintplex Labs)",
+ "license": "MIT",
+ "private": false,
+ "engines": {
+ "node": ">=18.12.1"
+ },
+ "scripts": {
+ "dev": "cross-env NODE_ENV=development nodemon --ignore documents --ignore vector-cache --ignore storage --ignore swagger --trace-warnings index.js",
+ "start": "cross-env NODE_ENV=production node index.js",
+ "lint": "yarn prettier --ignore-path ../.prettierignore --write ./endpoints ./models ./utils index.js",
+ "swagger": "node ./swagger/init.js"
+ },
+ "prisma": {
+ "seed": "node prisma/seed.js"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.39.0",
+ "@aws-sdk/client-bedrock-runtime": "^3.775.0",
+ "@datastax/astra-db-ts": "^0.1.3",
+ "@ladjs/graceful": "^3.2.2",
+ "@lancedb/lancedb": "0.15.0",
+ "@langchain/anthropic": "0.1.16",
+ "@langchain/aws": "^0.0.5",
+ "@langchain/community": "0.0.53",
+ "@langchain/core": "0.1.61",
+ "@langchain/openai": "0.0.28",
+ "@langchain/textsplitters": "0.0.0",
+ "@mintplex-labs/bree": "^9.2.5",
+ "@mintplex-labs/express-ws": "^5.0.7",
+ "@modelcontextprotocol/sdk": "^1.11.0",
+ "@pinecone-database/pinecone": "^2.0.1",
+ "@prisma/client": "5.3.1",
+ "@qdrant/js-client-rest": "^1.9.0",
+ "@xenova/transformers": "^2.14.0",
+ "@zilliz/milvus2-sdk-node": "^2.3.5",
+ "adm-zip": "^0.5.16",
+ "apache-arrow": "19.0.0",
+ "bcrypt": "^5.1.0",
+ "body-parser": "^1.20.2",
+ "chalk": "^4",
+ "check-disk-space": "^3.4.0",
+ "cheerio": "^1.0.0",
+ "chromadb": "^2.0.1",
+ "cohere-ai": "^7.9.5",
+ "cors": "^2.8.5",
+ "dotenv": "^16.0.3",
+ "elevenlabs": "^0.5.0",
+ "express": "^4.18.2",
+ "extract-json-from-string": "^1.0.1",
+ "fast-levenshtein": "^3.0.0",
+ "graphql": "^16.7.1",
+ "ip": "^2.0.1",
+ "joi": "^17.11.0",
+ "joi-password-complexity": "^5.2.0",
+ "js-tiktoken": "^1.0.8",
+ "jsonrepair": "^3.7.0",
+ "jsonwebtoken": "^9.0.0",
+ "langchain": "0.1.36",
+ "mime": "^3.0.0",
+ "moment": "^2.29.4",
+ "mssql": "^10.0.2",
+ "multer": "^1.4.5-lts.1",
+ "mysql2": "^3.9.8",
+ "ollama": "^0.5.10",
+ "openai": "4.95.1",
+ "pg": "^8.11.5",
+ "pinecone-client": "^1.1.0",
+ "pluralize": "^8.0.0",
+ "posthog-node": "^3.1.1",
+ "prisma": "5.3.1",
+ "slugify": "^1.6.6",
+ "swagger-autogen": "^2.23.5",
+ "swagger-ui-express": "^5.0.0",
+ "truncate": "^3.0.0",
+ "url-pattern": "^1.0.3",
+ "uuid": "^9.0.0",
+ "uuid-apikey": "^1.5.3",
+ "weaviate-ts-client": "^1.4.0",
+ "winston": "^3.13.0"
+ },
+ "devDependencies": {
+ "@inquirer/prompts": "^4.3.1",
+ "cross-env": "^7.0.3",
+ "eslint": "^8.50.0",
+ "eslint-config-prettier": "^9.0.0",
+ "eslint-plugin-ft-flow": "^3.0.0",
+ "eslint-plugin-prettier": "^5.0.0",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.3",
+ "flow-bin": "^0.217.0",
+ "flow-remove-types": "^2.217.1",
+ "globals": "^13.21.0",
+ "hermes-eslint": "^0.15.0",
+ "node-html-markdown": "^1.3.0",
+ "nodemon": "^2.0.22",
+ "prettier": "^3.0.3"
+ }
+}
\ No newline at end of file
diff --git a/server/prisma/migrations/20230921191814_init/migration.sql b/server/prisma/migrations/20230921191814_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..911559bae235c1ebab5f2c3fae14bc0ba050a1ae
--- /dev/null
+++ b/server/prisma/migrations/20230921191814_init/migration.sql
@@ -0,0 +1,125 @@
+-- CreateTable
+CREATE TABLE "api_keys" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "secret" TEXT,
+ "createdBy" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "workspace_documents" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "docId" TEXT NOT NULL,
+ "filename" TEXT NOT NULL,
+ "docpath" TEXT NOT NULL,
+ "workspaceId" INTEGER NOT NULL,
+ "metadata" TEXT,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_documents_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "invites" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "code" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "claimedBy" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdBy" INTEGER NOT NULL,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "system_settings" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "label" TEXT NOT NULL,
+ "value" TEXT,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "users" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "username" TEXT,
+ "password" TEXT NOT NULL,
+ "role" TEXT NOT NULL DEFAULT 'default',
+ "suspended" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "document_vectors" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "docId" TEXT NOT NULL,
+ "vectorId" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "welcome_messages" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "user" TEXT NOT NULL,
+ "response" TEXT NOT NULL,
+ "orderIndex" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "workspaces" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "name" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "vectorTag" TEXT,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "openAiTemp" REAL,
+ "openAiHistory" INTEGER NOT NULL DEFAULT 20,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "openAiPrompt" TEXT
+);
+
+-- CreateTable
+CREATE TABLE "workspace_chats" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "workspaceId" INTEGER NOT NULL,
+ "prompt" TEXT NOT NULL,
+ "response" TEXT NOT NULL,
+ "include" BOOLEAN NOT NULL DEFAULT true,
+ "user_id" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "workspace_users" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "user_id" INTEGER NOT NULL,
+ "workspace_id" INTEGER NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_users_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "workspace_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "api_keys_secret_key" ON "api_keys"("secret");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "workspace_documents_docId_key" ON "workspace_documents"("docId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "invites_code_key" ON "invites"("code");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "system_settings_label_key" ON "system_settings"("label");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "workspaces_slug_key" ON "workspaces"("slug");
diff --git a/server/prisma/migrations/20231101001441_init/migration.sql b/server/prisma/migrations/20231101001441_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..1c20f46e2fe7e808e8357689300a3a5820612f7f
--- /dev/null
+++ b/server/prisma/migrations/20231101001441_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "similarityThreshold" REAL DEFAULT 0.25;
diff --git a/server/prisma/migrations/20231101195421_init/migration.sql b/server/prisma/migrations/20231101195421_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..705bca3c3c4978f19e93ff545221dd36fbc5ccf7
--- /dev/null
+++ b/server/prisma/migrations/20231101195421_init/migration.sql
@@ -0,0 +1,11 @@
+-- CreateTable
+CREATE TABLE "cache_data" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "name" TEXT NOT NULL,
+ "data" TEXT NOT NULL,
+ "belongsTo" TEXT,
+ "byId" INTEGER,
+ "expiresAt" DATETIME,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/server/prisma/migrations/20231129012019_add/migration.sql b/server/prisma/migrations/20231129012019_add/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..7e37f7e89d906d5faae56848e2b1098138fedf8d
--- /dev/null
+++ b/server/prisma/migrations/20231129012019_add/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "pfpFilename" TEXT;
diff --git a/server/prisma/migrations/20240113013409_init/migration.sql b/server/prisma/migrations/20240113013409_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..09b9448ec84accd78806a932a9bd0a3172c55351
--- /dev/null
+++ b/server/prisma/migrations/20240113013409_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "chatModel" TEXT;
diff --git a/server/prisma/migrations/20240118201333_init/migration.sql b/server/prisma/migrations/20240118201333_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..aaf47f7af69ebdce6b199eecf53c82ea547631b1
--- /dev/null
+++ b/server/prisma/migrations/20240118201333_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "topN" INTEGER DEFAULT 4 CHECK ("topN" > 0);
diff --git a/server/prisma/migrations/20240202002020_init/migration.sql b/server/prisma/migrations/20240202002020_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..6035e7462f801595068621b9de80ee415f86c6e7
--- /dev/null
+++ b/server/prisma/migrations/20240202002020_init/migration.sql
@@ -0,0 +1,37 @@
+-- CreateTable
+CREATE TABLE "embed_configs" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "uuid" TEXT NOT NULL,
+ "enabled" BOOLEAN NOT NULL DEFAULT false,
+ "chat_mode" TEXT NOT NULL DEFAULT 'query',
+ "allowlist_domains" TEXT,
+ "allow_model_override" BOOLEAN NOT NULL DEFAULT false,
+ "allow_temperature_override" BOOLEAN NOT NULL DEFAULT false,
+ "allow_prompt_override" BOOLEAN NOT NULL DEFAULT false,
+ "max_chats_per_day" INTEGER,
+ "max_chats_per_session" INTEGER,
+ "workspace_id" INTEGER NOT NULL,
+ "createdBy" INTEGER,
+ "usersId" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "embed_configs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "embed_configs_usersId_fkey" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "embed_chats" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "prompt" TEXT NOT NULL,
+ "response" TEXT NOT NULL,
+ "session_id" TEXT NOT NULL,
+ "include" BOOLEAN NOT NULL DEFAULT true,
+ "connection_information" TEXT,
+ "embed_id" INTEGER NOT NULL,
+ "usersId" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "embed_chats_embed_id_fkey" FOREIGN KEY ("embed_id") REFERENCES "embed_configs" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "embed_chats_usersId_fkey" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "embed_configs_uuid_key" ON "embed_configs"("uuid");
diff --git a/server/prisma/migrations/20240206181106_init/migration.sql b/server/prisma/migrations/20240206181106_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..9655c7b7aecbff545c85cf61d42818a44c15cb5f
--- /dev/null
+++ b/server/prisma/migrations/20240206181106_init/migration.sql
@@ -0,0 +1,13 @@
+-- CreateTable
+CREATE TABLE "workspace_suggested_messages" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "workspaceId" INTEGER NOT NULL,
+ "heading" TEXT NOT NULL,
+ "message" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_suggested_messages_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE INDEX "workspace_suggested_messages_workspaceId_idx" ON "workspace_suggested_messages"("workspaceId");
diff --git a/server/prisma/migrations/20240206211916_init/migration.sql b/server/prisma/migrations/20240206211916_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..f2e882a0bb638b4e4b3c965ce7649d51edd4cd03
--- /dev/null
+++ b/server/prisma/migrations/20240206211916_init/migration.sql
@@ -0,0 +1,11 @@
+-- CreateTable
+CREATE TABLE "event_logs" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "event" TEXT NOT NULL,
+ "metadata" TEXT,
+ "userId" INTEGER,
+ "occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateIndex
+CREATE INDEX "event_logs_event_idx" ON "event_logs"("event");
diff --git a/server/prisma/migrations/20240208224848_init/migration.sql b/server/prisma/migrations/20240208224848_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..f7e65619bd1bff118613a0c8d1d88e6ccd2a9507
--- /dev/null
+++ b/server/prisma/migrations/20240208224848_init/migration.sql
@@ -0,0 +1,24 @@
+-- AlterTable
+ALTER TABLE "workspace_chats" ADD COLUMN "thread_id" INTEGER;
+
+-- CreateTable
+CREATE TABLE "workspace_threads" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "name" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "workspace_id" INTEGER NOT NULL,
+ "user_id" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_threads_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "workspace_threads_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "workspace_threads_slug_key" ON "workspace_threads"("slug");
+
+-- CreateIndex
+CREATE INDEX "workspace_threads_workspace_id_idx" ON "workspace_threads"("workspace_id");
+
+-- CreateIndex
+CREATE INDEX "workspace_threads_user_id_idx" ON "workspace_threads"("user_id");
diff --git a/server/prisma/migrations/20240210004405_init/migration.sql b/server/prisma/migrations/20240210004405_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..3d824ab05731e8dd54deb81b68207f0158aefd8e
--- /dev/null
+++ b/server/prisma/migrations/20240210004405_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspace_chats" ADD COLUMN "feedbackScore" BOOLEAN;
diff --git a/server/prisma/migrations/20240216214639_init/migration.sql b/server/prisma/migrations/20240216214639_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..368782bc9b550516872f251e8d602af7b5d22ebc
--- /dev/null
+++ b/server/prisma/migrations/20240216214639_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "chatMode" TEXT DEFAULT 'chat';
diff --git a/server/prisma/migrations/20240219211018_init/migration.sql b/server/prisma/migrations/20240219211018_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..98e8b24ad55b2c4964ebef2f26e909e8793675c6
--- /dev/null
+++ b/server/prisma/migrations/20240219211018_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspace_documents" ADD COLUMN "pinned" BOOLEAN DEFAULT false;
diff --git a/server/prisma/migrations/20240301002308_init/migration.sql b/server/prisma/migrations/20240301002308_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..5847beafd581ecf06181e51c6ffc7ddd15f49ab5
--- /dev/null
+++ b/server/prisma/migrations/20240301002308_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "pfpFilename" TEXT;
diff --git a/server/prisma/migrations/20240326231053_init/migration.sql b/server/prisma/migrations/20240326231053_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..85fe8be75576fbf15137fe2cc2b419e3668e7b08
--- /dev/null
+++ b/server/prisma/migrations/20240326231053_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "invites" ADD COLUMN "workspaceIds" TEXT;
diff --git a/server/prisma/migrations/20240405015034_init/migration.sql b/server/prisma/migrations/20240405015034_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..54a39d940a105816b68fa55b915b154978848e49
--- /dev/null
+++ b/server/prisma/migrations/20240405015034_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "chatProvider" TEXT;
diff --git a/server/prisma/migrations/20240412183346_init/migration.sql b/server/prisma/migrations/20240412183346_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..8014c9c3d0bd30396930c9f70cbd31caa9c5e09f
--- /dev/null
+++ b/server/prisma/migrations/20240412183346_init/migration.sql
@@ -0,0 +1,24 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "agentModel" TEXT;
+ALTER TABLE "workspaces" ADD COLUMN "agentProvider" TEXT;
+
+-- CreateTable
+CREATE TABLE "workspace_agent_invocations" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "uuid" TEXT NOT NULL,
+ "prompt" TEXT NOT NULL,
+ "closed" BOOLEAN NOT NULL DEFAULT false,
+ "user_id" INTEGER,
+ "thread_id" INTEGER,
+ "workspace_id" INTEGER NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_agent_invocations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "workspace_agent_invocations_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "workspace_agent_invocations_uuid_key" ON "workspace_agent_invocations"("uuid");
+
+-- CreateIndex
+CREATE INDEX "workspace_agent_invocations_uuid_idx" ON "workspace_agent_invocations"("uuid");
diff --git a/server/prisma/migrations/20240425004220_init/migration.sql b/server/prisma/migrations/20240425004220_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..14ec7643f9734c82f5d421f0fe8a3afb55056613
--- /dev/null
+++ b/server/prisma/migrations/20240425004220_init/migration.sql
@@ -0,0 +1,30 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "seen_recovery_codes" BOOLEAN DEFAULT false;
+
+-- CreateTable
+CREATE TABLE "recovery_codes" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "user_id" INTEGER NOT NULL,
+ "code_hash" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "recovery_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "password_reset_tokens" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "user_id" INTEGER NOT NULL,
+ "token" TEXT NOT NULL,
+ "expiresAt" DATETIME NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE INDEX "recovery_codes_user_id_idx" ON "recovery_codes"("user_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token");
+
+-- CreateIndex
+CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id");
diff --git a/server/prisma/migrations/20240430230707_init/migration.sql b/server/prisma/migrations/20240430230707_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..af29a13600c403a3700bda246e0e22d831ecc3c2
--- /dev/null
+++ b/server/prisma/migrations/20240430230707_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "queryRefusalResponse" TEXT;
diff --git a/server/prisma/migrations/20240510032311_init/migration.sql b/server/prisma/migrations/20240510032311_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..3b82efb885adde16937417be798393d792530c4f
--- /dev/null
+++ b/server/prisma/migrations/20240510032311_init/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "slash_command_presets" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "command" TEXT NOT NULL,
+ "prompt" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "uid" INTEGER NOT NULL DEFAULT 0,
+ "userId" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "slash_command_presets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "slash_command_presets_uid_command_key" ON "slash_command_presets"("uid", "command");
diff --git a/server/prisma/migrations/20240618224346_init/migration.sql b/server/prisma/migrations/20240618224346_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..cce17134a897430b3bb1ef57379232a06f40b629
--- /dev/null
+++ b/server/prisma/migrations/20240618224346_init/migration.sql
@@ -0,0 +1,26 @@
+-- AlterTable
+ALTER TABLE "workspace_documents" ADD COLUMN "watched" BOOLEAN DEFAULT false;
+
+-- CreateTable
+CREATE TABLE "document_sync_queues" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "staleAfterMs" INTEGER NOT NULL DEFAULT 604800000,
+ "nextSyncAt" DATETIME NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastSyncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "workspaceDocId" INTEGER NOT NULL,
+ CONSTRAINT "document_sync_queues_workspaceDocId_fkey" FOREIGN KEY ("workspaceDocId") REFERENCES "workspace_documents" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "document_sync_executions" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "queueId" INTEGER NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'unknown',
+ "result" TEXT,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "document_sync_executions_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "document_sync_queues" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "document_sync_queues_workspaceDocId_key" ON "document_sync_queues"("workspaceDocId");
diff --git a/server/prisma/migrations/20240821215625_init/migration.sql b/server/prisma/migrations/20240821215625_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..35bce1b30ee1ab35c2a5d21fd1d731da39dbbeec
--- /dev/null
+++ b/server/prisma/migrations/20240821215625_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspace_chats" ADD COLUMN "api_session_id" TEXT;
diff --git a/server/prisma/migrations/20240824005054_init/migration.sql b/server/prisma/migrations/20240824005054_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..7dc4632b642b122fffda940ba16357dc849feb34
--- /dev/null
+++ b/server/prisma/migrations/20240824005054_init/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "browser_extension_api_keys" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "key" TEXT NOT NULL,
+ "user_id" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL,
+ CONSTRAINT "browser_extension_api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "browser_extension_api_keys_key_key" ON "browser_extension_api_keys"("key");
+
+-- CreateIndex
+CREATE INDEX "browser_extension_api_keys_user_id_idx" ON "browser_extension_api_keys"("user_id");
diff --git a/server/prisma/migrations/20241003192954_init/migration.sql b/server/prisma/migrations/20241003192954_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..e3d26d35c440b501b03d70d8a812f54fe04c5ce0
--- /dev/null
+++ b/server/prisma/migrations/20241003192954_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "dailyMessageLimit" INTEGER;
diff --git a/server/prisma/migrations/20241029203722_init/migration.sql b/server/prisma/migrations/20241029203722_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..29ee89ad5a7212441daa22fa0006b9ffebf88f3a
--- /dev/null
+++ b/server/prisma/migrations/20241029203722_init/migration.sql
@@ -0,0 +1,12 @@
+-- CreateTable
+CREATE TABLE "temporary_auth_tokens" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "token" TEXT NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "expiresAt" DATETIME NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "temporary_auth_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "temporary_auth_tokens_token_key" ON "temporary_auth_tokens"("token");
diff --git a/server/prisma/migrations/20241029233509_init/migration.sql b/server/prisma/migrations/20241029233509_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..4540629ce0aa69863e00746b7b309137df0d6fbe
--- /dev/null
+++ b/server/prisma/migrations/20241029233509_init/migration.sql
@@ -0,0 +1,5 @@
+-- CreateIndex
+CREATE INDEX "temporary_auth_tokens_token_idx" ON "temporary_auth_tokens"("token");
+
+-- CreateIndex
+CREATE INDEX "temporary_auth_tokens_userId_idx" ON "temporary_auth_tokens"("userId");
diff --git a/server/prisma/migrations/20250102204948_init/migration.sql b/server/prisma/migrations/20250102204948_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..788409bfa1a3109fec5a3bbd7488c3c2593c2507
--- /dev/null
+++ b/server/prisma/migrations/20250102204948_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "workspaces" ADD COLUMN "vectorSearchMode" TEXT DEFAULT 'default';
diff --git a/server/prisma/migrations/20250226005538_init/migration.sql b/server/prisma/migrations/20250226005538_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..4902fbc68595041ad2937988a5c2a1f5a65e465d
--- /dev/null
+++ b/server/prisma/migrations/20250226005538_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "bio" TEXT DEFAULT '';
diff --git a/server/prisma/migrations/20250318154720_init/migration.sql b/server/prisma/migrations/20250318154720_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..810953ea77567f8d22a15288b18f496bd0488517
--- /dev/null
+++ b/server/prisma/migrations/20250318154720_init/migration.sql
@@ -0,0 +1,18 @@
+-- CreateTable
+CREATE TABLE "system_prompt_variables" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "key" TEXT NOT NULL,
+ "value" TEXT,
+ "description" TEXT,
+ "type" TEXT NOT NULL DEFAULT 'system',
+ "userId" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "system_prompt_variables_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "system_prompt_variables_key_key" ON "system_prompt_variables"("key");
+
+-- CreateIndex
+CREATE INDEX "system_prompt_variables_userId_idx" ON "system_prompt_variables"("userId");
diff --git a/server/prisma/migrations/20250506214129_init/migration.sql b/server/prisma/migrations/20250506214129_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..85deb92ad7f97b7baa1e588ce29dbd4ba6d8d361
--- /dev/null
+++ b/server/prisma/migrations/20250506214129_init/migration.sql
@@ -0,0 +1,13 @@
+-- CreateTable
+CREATE TABLE "prompt_history" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "workspaceId" INTEGER NOT NULL,
+ "prompt" TEXT NOT NULL,
+ "modifiedBy" INTEGER,
+ "modifiedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "prompt_history_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "prompt_history_modifiedBy_fkey" FOREIGN KEY ("modifiedBy") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE INDEX "prompt_history_workspaceId_idx" ON "prompt_history"("workspaceId");
diff --git a/server/prisma/migrations/20250709230835_init/migration.sql b/server/prisma/migrations/20250709230835_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..a183c5c2d3c97d4323ba0547e6efa9bb40557c75
--- /dev/null
+++ b/server/prisma/migrations/20250709230835_init/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "embed_configs" ADD COLUMN "message_limit" INTEGER DEFAULT 20;
diff --git a/server/prisma/migrations/20250725194841_init/migration.sql b/server/prisma/migrations/20250725194841_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..1510948831a1f1d9b5108129fd381e264015a966
--- /dev/null
+++ b/server/prisma/migrations/20250725194841_init/migration.sql
@@ -0,0 +1,17 @@
+-- CreateTable
+CREATE TABLE "desktop_mobile_devices" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "deviceOs" TEXT NOT NULL,
+ "deviceName" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "approved" BOOLEAN NOT NULL DEFAULT false,
+ "userId" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "desktop_mobile_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "desktop_mobile_devices_token_key" ON "desktop_mobile_devices"("token");
+
+-- CreateIndex
+CREATE INDEX "desktop_mobile_devices_userId_idx" ON "desktop_mobile_devices"("userId");
diff --git a/server/prisma/migrations/20250808171557_init/migration.sql b/server/prisma/migrations/20250808171557_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..4b3e7514bb54b12941e751ae018e4a00318ed730
--- /dev/null
+++ b/server/prisma/migrations/20250808171557_init/migration.sql
@@ -0,0 +1,23 @@
+-- CreateTable
+CREATE TABLE "workspace_parsed_files" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "filename" TEXT NOT NULL,
+ "workspaceId" INTEGER NOT NULL,
+ "userId" INTEGER,
+ "threadId" INTEGER,
+ "metadata" TEXT,
+ "tokenCountEstimate" INTEGER DEFAULT 0,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_parsed_files_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "workspace_parsed_files_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "workspace_parsed_files_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "workspace_threads" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "workspace_parsed_files_filename_key" ON "workspace_parsed_files"("filename");
+
+-- CreateIndex
+CREATE INDEX "workspace_parsed_files_workspaceId_idx" ON "workspace_parsed_files"("workspaceId");
+
+-- CreateIndex
+CREATE INDEX "workspace_parsed_files_userId_idx" ON "workspace_parsed_files"("userId");
diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000000000000000000000000000000000000..e5e5c4705ab084270b7de6f45d5291ba01666948
--- /dev/null
+++ b/server/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "sqlite"
\ No newline at end of file
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
new file mode 100644
index 0000000000000000000000000000000000000000..a3db69f1e2b3558c3d8dd792dd0fe5f516064476
--- /dev/null
+++ b/server/prisma/schema.prisma
@@ -0,0 +1,393 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+// Uncomment the following lines and comment out the SQLite datasource block above to use PostgreSQL
+// Make sure to set the correct DATABASE_URL in your .env file
+// After swapping run `yarn prisma:setup` from the root directory to migrate the database
+//
+// datasource db {
+// provider = "postgresql"
+// url = env("DATABASE_URL")
+// }
+datasource db {
+ provider = "sqlite"
+ url = "file:../storage/anythingllm.db"
+}
+
+model api_keys {
+ id Int @id @default(autoincrement())
+ secret String? @unique
+ createdBy Int?
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+}
+
+model workspace_documents {
+ id Int @id @default(autoincrement())
+ docId String @unique
+ filename String
+ docpath String
+ workspaceId Int
+ metadata String?
+ pinned Boolean? @default(false)
+ watched Boolean? @default(false)
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ workspace workspaces @relation(fields: [workspaceId], references: [id])
+ document_sync_queues document_sync_queues?
+}
+
+model invites {
+ id Int @id @default(autoincrement())
+ code String @unique
+ status String @default("pending")
+ claimedBy Int?
+ workspaceIds String?
+ createdAt DateTime @default(now())
+ createdBy Int
+ lastUpdatedAt DateTime @default(now())
+}
+
+model system_settings {
+ id Int @id @default(autoincrement())
+ label String @unique
+ value String?
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+}
+
+model users {
+ id Int @id @default(autoincrement())
+ username String? @unique
+ password String
+ pfpFilename String?
+ role String @default("default")
+ suspended Int @default(0)
+ seen_recovery_codes Boolean? @default(false)
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ dailyMessageLimit Int?
+ bio String? @default("")
+ workspace_chats workspace_chats[]
+ workspace_users workspace_users[]
+ embed_configs embed_configs[]
+ embed_chats embed_chats[]
+ threads workspace_threads[]
+ recovery_codes recovery_codes[]
+ password_reset_tokens password_reset_tokens[]
+ workspace_agent_invocations workspace_agent_invocations[]
+ slash_command_presets slash_command_presets[]
+ browser_extension_api_keys browser_extension_api_keys[]
+ temporary_auth_tokens temporary_auth_tokens[]
+ system_prompt_variables system_prompt_variables[]
+ prompt_history prompt_history[]
+ desktop_mobile_devices desktop_mobile_devices[]
+ workspace_parsed_files workspace_parsed_files[]
+}
+
+model recovery_codes {
+ id Int @id @default(autoincrement())
+ user_id Int
+ code_hash String
+ createdAt DateTime @default(now())
+ user users @relation(fields: [user_id], references: [id], onDelete: Cascade)
+
+ @@index([user_id])
+}
+
+model password_reset_tokens {
+ id Int @id @default(autoincrement())
+ user_id Int
+ token String @unique
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+ user users @relation(fields: [user_id], references: [id], onDelete: Cascade)
+
+ @@index([user_id])
+}
+
+model document_vectors {
+ id Int @id @default(autoincrement())
+ docId String
+ vectorId String
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+}
+
+model welcome_messages {
+ id Int @id @default(autoincrement())
+ user String
+ response String
+ orderIndex Int?
+ createdAt DateTime @default(now())
+}
+
+model workspaces {
+ id Int @id @default(autoincrement())
+ name String
+ slug String @unique
+ vectorTag String?
+ createdAt DateTime @default(now())
+ openAiTemp Float?
+ openAiHistory Int @default(20)
+ lastUpdatedAt DateTime @default(now())
+ openAiPrompt String?
+ similarityThreshold Float? @default(0.25)
+ chatProvider String?
+ chatModel String?
+ topN Int? @default(4)
+ chatMode String? @default("chat")
+ pfpFilename String?
+ agentProvider String?
+ agentModel String?
+ queryRefusalResponse String?
+ vectorSearchMode String? @default("default")
+ workspace_users workspace_users[]
+ documents workspace_documents[]
+ workspace_suggested_messages workspace_suggested_messages[]
+ embed_configs embed_configs[]
+ threads workspace_threads[]
+ workspace_agent_invocations workspace_agent_invocations[]
+ prompt_history prompt_history[]
+ workspace_parsed_files workspace_parsed_files[]
+}
+
+model workspace_threads {
+ id Int @id @default(autoincrement())
+ name String
+ slug String @unique
+ workspace_id Int
+ user_id Int?
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
+ user users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
+ workspace_parsed_files workspace_parsed_files[]
+
+ @@index([workspace_id])
+ @@index([user_id])
+}
+
+model workspace_suggested_messages {
+ id Int @id @default(autoincrement())
+ workspaceId Int
+ heading String
+ message String
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
+
+ @@index([workspaceId])
+}
+
+model workspace_chats {
+ id Int @id @default(autoincrement())
+ workspaceId Int
+ prompt String
+ response String
+ include Boolean @default(true)
+ user_id Int?
+ thread_id Int? // No relation to prevent whole table migration
+ api_session_id String? // String identifier for only the dev API to partition chats in any mode.
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ feedbackScore Boolean?
+ users users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
+}
+
+model workspace_agent_invocations {
+ id Int @id @default(autoincrement())
+ uuid String @unique
+ prompt String // Contains agent invocation to parse + option additional text for seed.
+ closed Boolean @default(false)
+ user_id Int?
+ thread_id Int? // No relation to prevent whole table migration
+ workspace_id Int
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ user users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
+
+ @@index([uuid])
+}
+
+model workspace_users {
+ id Int @id @default(autoincrement())
+ user_id Int
+ workspace_id Int
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
+}
+
+model cache_data {
+ id Int @id @default(autoincrement())
+ name String
+ data String
+ belongsTo String?
+ byId Int?
+ expiresAt DateTime?
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+}
+
+model embed_configs {
+ id Int @id @default(autoincrement())
+ uuid String @unique
+ enabled Boolean @default(false)
+ chat_mode String @default("query")
+ allowlist_domains String?
+ allow_model_override Boolean @default(false)
+ allow_temperature_override Boolean @default(false)
+ allow_prompt_override Boolean @default(false)
+ max_chats_per_day Int?
+ max_chats_per_session Int?
+ message_limit Int? @default(20)
+ workspace_id Int
+ createdBy Int?
+ usersId Int?
+ createdAt DateTime @default(now())
+ workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
+ embed_chats embed_chats[]
+ users users? @relation(fields: [usersId], references: [id])
+}
+
+model embed_chats {
+ id Int @id @default(autoincrement())
+ prompt String
+ response String
+ session_id String
+ include Boolean @default(true)
+ connection_information String?
+ embed_id Int
+ usersId Int?
+ createdAt DateTime @default(now())
+ embed_config embed_configs @relation(fields: [embed_id], references: [id], onDelete: Cascade)
+ users users? @relation(fields: [usersId], references: [id])
+}
+
+model event_logs {
+ id Int @id @default(autoincrement())
+ event String
+ metadata String?
+ userId Int?
+ occurredAt DateTime @default(now())
+
+ @@index([event])
+}
+
+model slash_command_presets {
+ id Int @id @default(autoincrement())
+ command String
+ prompt String
+ description String
+ uid Int @default(0) // 0 is null user
+ userId Int?
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @default(now())
+ user users? @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([uid, command])
+}
+
+model document_sync_queues {
+ id Int @id @default(autoincrement())
+ staleAfterMs Int @default(604800000) // 7 days
+ nextSyncAt DateTime
+ createdAt DateTime @default(now())
+ lastSyncedAt DateTime @default(now())
+ workspaceDocId Int @unique
+ workspaceDoc workspace_documents? @relation(fields: [workspaceDocId], references: [id], onDelete: Cascade)
+ runs document_sync_executions[]
+}
+
+model document_sync_executions {
+ id Int @id @default(autoincrement())
+ queueId Int
+ status String @default("unknown")
+ result String?
+ createdAt DateTime @default(now())
+ queue document_sync_queues @relation(fields: [queueId], references: [id], onDelete: Cascade)
+}
+
+model browser_extension_api_keys {
+ id Int @id @default(autoincrement())
+ key String @unique
+ user_id Int?
+ createdAt DateTime @default(now())
+ lastUpdatedAt DateTime @updatedAt
+ user users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
+
+ @@index([user_id])
+}
+
+model temporary_auth_tokens {
+ id Int @id @default(autoincrement())
+ token String @unique
+ userId Int
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+ user users @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([token])
+ @@index([userId])
+}
+
+model system_prompt_variables {
+ id Int @id @default(autoincrement())
+ key String @unique
+ value String?
+ description String?
+ type String @default("system") // system, user, dynamic
+ userId Int?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ user users? @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+}
+
+model prompt_history {
+ id Int @id @default(autoincrement())
+ workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
+ workspaceId Int
+ prompt String
+ modifiedBy Int?
+ modifiedAt DateTime @default(now())
+ user users? @relation(fields: [modifiedBy], references: [id])
+
+ @@index([workspaceId])
+}
+
+// Schema specific to mobile app <> Desktop app connection
+model desktop_mobile_devices {
+ id Int @id @default(autoincrement())
+ deviceOs String
+ deviceName String
+ token String @unique
+ approved Boolean @default(false)
+ userId Int?
+ createdAt DateTime @default(now())
+ user users? @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+}
+
+model workspace_parsed_files {
+ id Int @id @default(autoincrement())
+ filename String @unique
+ workspaceId Int
+ userId Int?
+ threadId Int?
+ metadata String?
+ tokenCountEstimate Int? @default(0)
+ createdAt DateTime @default(now())
+ workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
+ user users? @relation(fields: [userId], references: [id], onDelete: Cascade)
+ thread workspace_threads? @relation(fields: [threadId], references: [id], onDelete: Cascade)
+
+ @@index([workspaceId])
+ @@index([userId])
+}
diff --git a/server/prisma/seed.js b/server/prisma/seed.js
new file mode 100644
index 0000000000000000000000000000000000000000..202ac04b32b4321b6ed90f32b5fcf26334506515
--- /dev/null
+++ b/server/prisma/seed.js
@@ -0,0 +1,31 @@
+const { PrismaClient } = require("@prisma/client");
+const prisma = new PrismaClient();
+
+async function main() {
+ const settings = [
+ { label: "multi_user_mode", value: "false" },
+ { label: "logo_filename", value: "anything-llm.png" },
+ ];
+
+ for (let setting of settings) {
+ const existing = await prisma.system_settings.findUnique({
+ where: { label: setting.label },
+ });
+
+ // Only create the setting if it doesn't already exist
+ if (!existing) {
+ await prisma.system_settings.create({
+ data: setting,
+ });
+ }
+ }
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/server/storage/README.md b/server/storage/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1282f4b37241a00af4282f2a2ee90f03fb187c14
--- /dev/null
+++ b/server/storage/README.md
@@ -0,0 +1,24 @@
+# AnythingLLM Storage
+
+This folder is for the local or disk storage of ready-to-embed documents, vector-cached embeddings, and the disk-storage of LanceDB and the local SQLite database.
+
+This folder should contain the following folders.
+`documents`
+`lancedb` (if using lancedb)
+`vector-cache`
+and a file named exactly `anythingllm.db`
+
+
+### Common issues
+**SQLITE_FILE_CANNOT_BE_OPENED** in the server log = The DB file does not exist probably because the node instance does not have the correct permissions to write a file to the disk. To solve this..
+
+- Local dev
+ - Create a `anythingllm.db` empty file in this directory. Thats all. No need to reboot the server or anything. If your permissions are correct this should not ever occur since the server will create the file if it does not exist automatically.
+
+- Docker Instance
+ - Get your AnythingLLM docker container id with `docker ps -a`. The container must be running to execute the next commands.
+ - Run `docker container exec -u 0 -t mkdir -p /app/server/storage /app/server/storage/documents /app/server/storage/vector-cache /app/server/storage/lancedb`
+ - Run `docker container exec -u 0 -t touch /app/server/storage/anythingllm.db`
+ - Run `docker container exec -u 0 -t chown -R anythingllm:anythingllm /app/collector /app/server`
+
+ - The above commands will create the appropriate folders inside of the docker container and will persist as long as you do not destroy the container and volume. This will also fix any ownership issues of folder files in the collector and the server.
\ No newline at end of file
diff --git a/server/storage/assets/anything-llm.png b/server/storage/assets/anything-llm.png
new file mode 100644
index 0000000000000000000000000000000000000000..5788ad63fe80b8a914097d1fe5a9d659be12e647
--- /dev/null
+++ b/server/storage/assets/anything-llm.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:67ade0a8602acac0e32cf167d68689bf38523ffd484346e12fcfdf9e7e59b93f
+size 6324
diff --git a/server/storage/models/.gitignore b/server/storage/models/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..7f5c5f8bed17d94b691689d1d0453645a739b7fa
--- /dev/null
+++ b/server/storage/models/.gitignore
@@ -0,0 +1,14 @@
+Xenova
+downloaded/*
+!downloaded/.placeholder
+openrouter
+apipie
+novita
+mixedbread-ai*
+gemini
+togetherAi
+tesseract
+ppio
+context-windows/*
+MintplexLabs
+cometapi
\ No newline at end of file
diff --git a/server/storage/models/README.md b/server/storage/models/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..432f60572ad8dd453395725e0d7ab382acb6bba4
--- /dev/null
+++ b/server/storage/models/README.md
@@ -0,0 +1,45 @@
+# Native models used by AnythingLLM
+
+This folder is specifically created as a local cache and storage folder that is used for native models that can run on a CPU.
+
+Currently, AnythingLLM uses this folder for the following parts of the application.
+
+## Embedding
+When your embedding engine preference is `native` we will use the ONNX **all-MiniLM-L6-v2** model built by [Xenova on HuggingFace.co](https://huggingface.co/Xenova/all-MiniLM-L6-v2). This model is a quantized and WASM version of the popular [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) which produces a 384-dimension vector.
+
+If you are using the `native` embedding engine your vector database should be configured to accept 384-dimension models if that parameter is directly editable (Pinecone only).
+
+## Audio/Video transcription
+AnythingLLM allows you to upload various audio and video formats as source documents. In all cases the audio tracks will be transcribed by a locally running ONNX model **whisper-small** built by [Xenova on HuggingFace.co](https://huggingface.co/Xenova/whisper-small). The model is a smaller version of the OpenAI Whisper model. Given the model runs locally on CPU, larger files will result in longer transcription times.
+
+Once transcribed you can embed these transcriptions into your workspace like you would any other file!
+
+**Other external model/transcription providers are also live.**
+- [OpenAI Whisper via API key.](https://openai.com/research/whisper)
+
+## Text generation (LLM selection)
+> [!IMPORTANT]
+> Use of a locally running LLM model is **experimental** and may behave unexpectedly, crash, or not function at all.
+> We suggest for production-use of a local LLM model to use a purpose-built inference server like [LocalAI](https://localai.io) or [LMStudio](https://lmstudio.ai).
+
+> [!TIP]
+> We recommend at _least_ using a 4-bit or 5-bit quantized model for your LLM. Lower quantization models tend to
+> just output unreadable garbage.
+
+If you would like to use a local Llama compatible LLM model for chatting you can select any model from this [HuggingFace search filter](https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&other=text-generation-inference&sort=trending)
+
+**Requirements**
+- Model must be in the latest `GGUF` format
+- Model should be compatible with latest `llama.cpp`
+- You should have the proper RAM to run such a model. Requirement depends on model size.
+
+### Where do I put my GGUF model?
+> [!IMPORTANT]
+> If running in Docker you should be running the container to a mounted storage location on the host machine so you
+> can update the storage files directly without having to re-download or re-build your docker container. [See suggested Docker config](../../../README.md#recommended-usage-with-docker-easy)
+
+> [!NOTE]
+> `/server/storage/models/downloaded` is the default location that your model files should be at.
+> Your storage directory may differ if you changed the STORAGE_DIR environment variable.
+
+All local models you want to have available for LLM selection should be placed in the `server/storage/models/downloaded` folder. Only `.gguf` files will be allowed to be selected from the UI.
\ No newline at end of file
diff --git a/server/storage/models/downloaded/.placeholder b/server/storage/models/downloaded/.placeholder
new file mode 100644
index 0000000000000000000000000000000000000000..6121f69757ebf5100f826c8ed2358ca524d894c2
--- /dev/null
+++ b/server/storage/models/downloaded/.placeholder
@@ -0,0 +1 @@
+All your .GGUF model file downloads you want to use for chatting should go into this folder.
\ No newline at end of file
diff --git a/server/swagger/dark-swagger.css b/server/swagger/dark-swagger.css
new file mode 100644
index 0000000000000000000000000000000000000000..574e1d953185d12852673d650887b03b85e82311
--- /dev/null
+++ b/server/swagger/dark-swagger.css
@@ -0,0 +1,1722 @@
+@media only screen and (prefers-color-scheme: dark) {
+
+ a {
+ color: #8c8cfa;
+ }
+
+ ::-webkit-scrollbar-track-piece {
+ background-color: rgba(255, 255, 255, .2) !important;
+ }
+
+ ::-webkit-scrollbar-track {
+ background-color: rgba(255, 255, 255, .3) !important;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-color: rgba(255, 255, 255, .5) !important;
+ }
+
+ embed[type="application/pdf"] {
+ filter: invert(90%);
+ }
+
+ html {
+ background: #1f1f1f !important;
+ box-sizing: border-box;
+ filter: contrast(100%) brightness(100%) saturate(100%);
+ overflow-y: scroll;
+ }
+
+ body {
+ background: #1f1f1f;
+ background-color: #1f1f1f;
+ background-image: none !important;
+ }
+
+ button,
+ input,
+ select,
+ textarea {
+ background-color: #1f1f1f;
+ color: #bfbfbf;
+ }
+
+ font,
+ html {
+ color: #bfbfbf;
+ }
+
+ .swagger-ui,
+ .swagger-ui section h3 {
+ color: #b5bac9;
+ }
+
+ .swagger-ui a {
+ background-color: transparent;
+ }
+
+ .swagger-ui mark {
+ background-color: #664b00;
+ color: #bfbfbf;
+ }
+
+ .swagger-ui legend {
+ color: inherit;
+ }
+
+ .swagger-ui .debug * {
+ outline: #e6da99 solid 1px;
+ }
+
+ .swagger-ui .debug-white * {
+ outline: #fff solid 1px;
+ }
+
+ .swagger-ui .debug-black * {
+ outline: #bfbfbf solid 1px;
+ }
+
+ .swagger-ui .debug-grid {
+ background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) 0 0;
+ }
+
+ .swagger-ui .debug-grid-16 {
+ background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) 0 0;
+ }
+
+ .swagger-ui .debug-grid-8-solid {
+ background: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) 0 0 #1c1c21;
+ }
+
+ .swagger-ui .debug-grid-16-solid {
+ background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) 0 0 #1c1c21;
+ }
+
+ .swagger-ui .b--black {
+ border-color: #000;
+ }
+
+ .swagger-ui .b--near-black {
+ border-color: #121212;
+ }
+
+ .swagger-ui .b--dark-gray {
+ border-color: #333;
+ }
+
+ .swagger-ui .b--mid-gray {
+ border-color: #545454;
+ }
+
+ .swagger-ui .b--gray {
+ border-color: #787878;
+ }
+
+ .swagger-ui .b--silver {
+ border-color: #999;
+ }
+
+ .swagger-ui .b--light-silver {
+ border-color: #6e6e6e;
+ }
+
+ .swagger-ui .b--moon-gray {
+ border-color: #4d4d4d;
+ }
+
+ .swagger-ui .b--light-gray {
+ border-color: #2b2b2b;
+ }
+
+ .swagger-ui .b--near-white {
+ border-color: #242424;
+ }
+
+ .swagger-ui .b--white {
+ border-color: #1c1c21;
+ }
+
+ .swagger-ui .b--white-90 {
+ border-color: rgba(28, 28, 33, .9);
+ }
+
+ .swagger-ui .b--white-80 {
+ border-color: rgba(28, 28, 33, .8);
+ }
+
+ .swagger-ui .b--white-70 {
+ border-color: rgba(28, 28, 33, .7);
+ }
+
+ .swagger-ui .b--white-60 {
+ border-color: rgba(28, 28, 33, .6);
+ }
+
+ .swagger-ui .b--white-50 {
+ border-color: rgba(28, 28, 33, .5);
+ }
+
+ .swagger-ui .b--white-40 {
+ border-color: rgba(28, 28, 33, .4);
+ }
+
+ .swagger-ui .b--white-30 {
+ border-color: rgba(28, 28, 33, .3);
+ }
+
+ .swagger-ui .b--white-20 {
+ border-color: rgba(28, 28, 33, .2);
+ }
+
+ .swagger-ui .b--white-10 {
+ border-color: rgba(28, 28, 33, .1);
+ }
+
+ .swagger-ui .b--white-05 {
+ border-color: rgba(28, 28, 33, .05);
+ }
+
+ .swagger-ui .b--white-025 {
+ border-color: rgba(28, 28, 33, .024);
+ }
+
+ .swagger-ui .b--white-0125 {
+ border-color: rgba(28, 28, 33, .01);
+ }
+
+ .swagger-ui .b--black-90 {
+ border-color: rgba(0, 0, 0, .9);
+ }
+
+ .swagger-ui .b--black-80 {
+ border-color: rgba(0, 0, 0, .8);
+ }
+
+ .swagger-ui .b--black-70 {
+ border-color: rgba(0, 0, 0, .7);
+ }
+
+ .swagger-ui .b--black-60 {
+ border-color: rgba(0, 0, 0, .6);
+ }
+
+ .swagger-ui .b--black-50 {
+ border-color: rgba(0, 0, 0, .5);
+ }
+
+ .swagger-ui .b--black-40 {
+ border-color: rgba(0, 0, 0, .4);
+ }
+
+ .swagger-ui .b--black-30 {
+ border-color: rgba(0, 0, 0, .3);
+ }
+
+ .swagger-ui .b--black-20 {
+ border-color: rgba(0, 0, 0, .2);
+ }
+
+ .swagger-ui .b--black-10 {
+ border-color: rgba(0, 0, 0, .1);
+ }
+
+ .swagger-ui .b--black-05 {
+ border-color: rgba(0, 0, 0, .05);
+ }
+
+ .swagger-ui .b--black-025 {
+ border-color: rgba(0, 0, 0, .024);
+ }
+
+ .swagger-ui .b--black-0125 {
+ border-color: rgba(0, 0, 0, .01);
+ }
+
+ .swagger-ui .b--dark-red {
+ border-color: #bc2f36;
+ }
+
+ .swagger-ui .b--red {
+ border-color: #c83932;
+ }
+
+ .swagger-ui .b--light-red {
+ border-color: #ab3c2b;
+ }
+
+ .swagger-ui .b--orange {
+ border-color: #cc6e33;
+ }
+
+ .swagger-ui .b--purple {
+ border-color: #5e2ca5;
+ }
+
+ .swagger-ui .b--light-purple {
+ border-color: #672caf;
+ }
+
+ .swagger-ui .b--dark-pink {
+ border-color: #ab2b81;
+ }
+
+ .swagger-ui .b--hot-pink {
+ border-color: #c03086;
+ }
+
+ .swagger-ui .b--pink {
+ border-color: #8f2464;
+ }
+
+ .swagger-ui .b--light-pink {
+ border-color: #721d4d;
+ }
+
+ .swagger-ui .b--dark-green {
+ border-color: #1c6e50;
+ }
+
+ .swagger-ui .b--green {
+ border-color: #279b70;
+ }
+
+ .swagger-ui .b--light-green {
+ border-color: #228762;
+ }
+
+ .swagger-ui .b--navy {
+ border-color: #0d1d35;
+ }
+
+ .swagger-ui .b--dark-blue {
+ border-color: #20497e;
+ }
+
+ .swagger-ui .b--blue {
+ border-color: #4380d0;
+ }
+
+ .swagger-ui .b--light-blue {
+ border-color: #20517e;
+ }
+
+ .swagger-ui .b--lightest-blue {
+ border-color: #143a52;
+ }
+
+ .swagger-ui .b--washed-blue {
+ border-color: #0c312d;
+ }
+
+ .swagger-ui .b--washed-green {
+ border-color: #0f3d2c;
+ }
+
+ .swagger-ui .b--washed-red {
+ border-color: #411010;
+ }
+
+ .swagger-ui .b--transparent {
+ border-color: transparent;
+ }
+
+ .swagger-ui .b--gold,
+ .swagger-ui .b--light-yellow,
+ .swagger-ui .b--washed-yellow,
+ .swagger-ui .b--yellow {
+ border-color: #664b00;
+ }
+
+ .swagger-ui .shadow-1 {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px;
+ }
+
+ .swagger-ui .shadow-2 {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px;
+ }
+
+ .swagger-ui .shadow-3 {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px;
+ }
+
+ .swagger-ui .shadow-4 {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0;
+ }
+
+ .swagger-ui .shadow-5 {
+ box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0;
+ }
+
+ @media screen and (min-width: 30em) {
+ .swagger-ui .shadow-1-ns {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px;
+ }
+
+ .swagger-ui .shadow-2-ns {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px;
+ }
+
+ .swagger-ui .shadow-3-ns {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px;
+ }
+
+ .swagger-ui .shadow-4-ns {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0;
+ }
+
+ .swagger-ui .shadow-5-ns {
+ box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0;
+ }
+ }
+
+ @media screen and (max-width: 60em) and (min-width: 30em) {
+ .swagger-ui .shadow-1-m {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px;
+ }
+
+ .swagger-ui .shadow-2-m {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px;
+ }
+
+ .swagger-ui .shadow-3-m {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px;
+ }
+
+ .swagger-ui .shadow-4-m {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0;
+ }
+
+ .swagger-ui .shadow-5-m {
+ box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0;
+ }
+ }
+
+ @media screen and (min-width: 60em) {
+ .swagger-ui .shadow-1-l {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px;
+ }
+
+ .swagger-ui .shadow-2-l {
+ box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px;
+ }
+
+ .swagger-ui .shadow-3-l {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px;
+ }
+
+ .swagger-ui .shadow-4-l {
+ box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0;
+ }
+
+ .swagger-ui .shadow-5-l {
+ box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0;
+ }
+ }
+
+ .swagger-ui .black-05 {
+ color: rgba(191, 191, 191, .05);
+ }
+
+ .swagger-ui .bg-black-05 {
+ background-color: rgba(0, 0, 0, .05);
+ }
+
+ .swagger-ui .black-90,
+ .swagger-ui .hover-black-90:focus,
+ .swagger-ui .hover-black-90:hover {
+ color: rgba(191, 191, 191, .9);
+ }
+
+ .swagger-ui .black-80,
+ .swagger-ui .hover-black-80:focus,
+ .swagger-ui .hover-black-80:hover {
+ color: rgba(191, 191, 191, .8);
+ }
+
+ .swagger-ui .black-70,
+ .swagger-ui .hover-black-70:focus,
+ .swagger-ui .hover-black-70:hover {
+ color: rgba(191, 191, 191, .7);
+ }
+
+ .swagger-ui .black-60,
+ .swagger-ui .hover-black-60:focus,
+ .swagger-ui .hover-black-60:hover {
+ color: rgba(191, 191, 191, .6);
+ }
+
+ .swagger-ui .black-50,
+ .swagger-ui .hover-black-50:focus,
+ .swagger-ui .hover-black-50:hover {
+ color: rgba(191, 191, 191, .5);
+ }
+
+ .swagger-ui .black-40,
+ .swagger-ui .hover-black-40:focus,
+ .swagger-ui .hover-black-40:hover {
+ color: rgba(191, 191, 191, .4);
+ }
+
+ .swagger-ui .black-30,
+ .swagger-ui .hover-black-30:focus,
+ .swagger-ui .hover-black-30:hover {
+ color: rgba(191, 191, 191, .3);
+ }
+
+ .swagger-ui .black-20,
+ .swagger-ui .hover-black-20:focus,
+ .swagger-ui .hover-black-20:hover {
+ color: rgba(191, 191, 191, .2);
+ }
+
+ .swagger-ui .black-10,
+ .swagger-ui .hover-black-10:focus,
+ .swagger-ui .hover-black-10:hover {
+ color: rgba(191, 191, 191, .1);
+ }
+
+ .swagger-ui .hover-white-90:focus,
+ .swagger-ui .hover-white-90:hover,
+ .swagger-ui .white-90 {
+ color: rgba(255, 255, 255, .9);
+ }
+
+ .swagger-ui .hover-white-80:focus,
+ .swagger-ui .hover-white-80:hover,
+ .swagger-ui .white-80 {
+ color: rgba(255, 255, 255, .8);
+ }
+
+ .swagger-ui .hover-white-70:focus,
+ .swagger-ui .hover-white-70:hover,
+ .swagger-ui .white-70 {
+ color: rgba(255, 255, 255, .7);
+ }
+
+ .swagger-ui .hover-white-60:focus,
+ .swagger-ui .hover-white-60:hover,
+ .swagger-ui .white-60 {
+ color: rgba(255, 255, 255, .6);
+ }
+
+ .swagger-ui .hover-white-50:focus,
+ .swagger-ui .hover-white-50:hover,
+ .swagger-ui .white-50 {
+ color: rgba(255, 255, 255, .5);
+ }
+
+ .swagger-ui .hover-white-40:focus,
+ .swagger-ui .hover-white-40:hover,
+ .swagger-ui .white-40 {
+ color: rgba(255, 255, 255, .4);
+ }
+
+ .swagger-ui .hover-white-30:focus,
+ .swagger-ui .hover-white-30:hover,
+ .swagger-ui .white-30 {
+ color: rgba(255, 255, 255, .3);
+ }
+
+ .swagger-ui .hover-white-20:focus,
+ .swagger-ui .hover-white-20:hover,
+ .swagger-ui .white-20 {
+ color: rgba(255, 255, 255, .2);
+ }
+
+ .swagger-ui .hover-white-10:focus,
+ .swagger-ui .hover-white-10:hover,
+ .swagger-ui .white-10 {
+ color: rgba(255, 255, 255, .1);
+ }
+
+ .swagger-ui .hover-moon-gray:focus,
+ .swagger-ui .hover-moon-gray:hover,
+ .swagger-ui .moon-gray {
+ color: #ccc;
+ }
+
+ .swagger-ui .hover-light-gray:focus,
+ .swagger-ui .hover-light-gray:hover,
+ .swagger-ui .light-gray {
+ color: #ededed;
+ }
+
+ .swagger-ui .hover-near-white:focus,
+ .swagger-ui .hover-near-white:hover,
+ .swagger-ui .near-white {
+ color: #f5f5f5;
+ }
+
+ .swagger-ui .dark-red,
+ .swagger-ui .hover-dark-red:focus,
+ .swagger-ui .hover-dark-red:hover {
+ color: #e6999d;
+ }
+
+ .swagger-ui .hover-red:focus,
+ .swagger-ui .hover-red:hover,
+ .swagger-ui .red {
+ color: #e69d99;
+ }
+
+ .swagger-ui .hover-light-red:focus,
+ .swagger-ui .hover-light-red:hover,
+ .swagger-ui .light-red {
+ color: #e6a399;
+ }
+
+ .swagger-ui .hover-orange:focus,
+ .swagger-ui .hover-orange:hover,
+ .swagger-ui .orange {
+ color: #e6b699;
+ }
+
+ .swagger-ui .gold,
+ .swagger-ui .hover-gold:focus,
+ .swagger-ui .hover-gold:hover {
+ color: #e6d099;
+ }
+
+ .swagger-ui .hover-yellow:focus,
+ .swagger-ui .hover-yellow:hover,
+ .swagger-ui .yellow {
+ color: #e6da99;
+ }
+
+ .swagger-ui .hover-light-yellow:focus,
+ .swagger-ui .hover-light-yellow:hover,
+ .swagger-ui .light-yellow {
+ color: #ede6b6;
+ }
+
+ .swagger-ui .hover-purple:focus,
+ .swagger-ui .hover-purple:hover,
+ .swagger-ui .purple {
+ color: #b99ae4;
+ }
+
+ .swagger-ui .hover-light-purple:focus,
+ .swagger-ui .hover-light-purple:hover,
+ .swagger-ui .light-purple {
+ color: #bb99e6;
+ }
+
+ .swagger-ui .dark-pink,
+ .swagger-ui .hover-dark-pink:focus,
+ .swagger-ui .hover-dark-pink:hover {
+ color: #e699cc;
+ }
+
+ .swagger-ui .hot-pink,
+ .swagger-ui .hover-hot-pink:focus,
+ .swagger-ui .hover-hot-pink:hover,
+ .swagger-ui .hover-pink:focus,
+ .swagger-ui .hover-pink:hover,
+ .swagger-ui .pink {
+ color: #e699c7;
+ }
+
+ .swagger-ui .hover-light-pink:focus,
+ .swagger-ui .hover-light-pink:hover,
+ .swagger-ui .light-pink {
+ color: #edb6d5;
+ }
+
+ .swagger-ui .dark-green,
+ .swagger-ui .green,
+ .swagger-ui .hover-dark-green:focus,
+ .swagger-ui .hover-dark-green:hover,
+ .swagger-ui .hover-green:focus,
+ .swagger-ui .hover-green:hover {
+ color: #99e6c9;
+ }
+
+ .swagger-ui .hover-light-green:focus,
+ .swagger-ui .hover-light-green:hover,
+ .swagger-ui .light-green {
+ color: #a1e8ce;
+ }
+
+ .swagger-ui .hover-navy:focus,
+ .swagger-ui .hover-navy:hover,
+ .swagger-ui .navy {
+ color: #99b8e6;
+ }
+
+ .swagger-ui .blue,
+ .swagger-ui .dark-blue,
+ .swagger-ui .hover-blue:focus,
+ .swagger-ui .hover-blue:hover,
+ .swagger-ui .hover-dark-blue:focus,
+ .swagger-ui .hover-dark-blue:hover {
+ color: #99bae6;
+ }
+
+ .swagger-ui .hover-light-blue:focus,
+ .swagger-ui .hover-light-blue:hover,
+ .swagger-ui .light-blue {
+ color: #a9cbea;
+ }
+
+ .swagger-ui .hover-lightest-blue:focus,
+ .swagger-ui .hover-lightest-blue:hover,
+ .swagger-ui .lightest-blue {
+ color: #d6e9f5;
+ }
+
+ .swagger-ui .hover-washed-blue:focus,
+ .swagger-ui .hover-washed-blue:hover,
+ .swagger-ui .washed-blue {
+ color: #f7fdfc;
+ }
+
+ .swagger-ui .hover-washed-green:focus,
+ .swagger-ui .hover-washed-green:hover,
+ .swagger-ui .washed-green {
+ color: #ebfaf4;
+ }
+
+ .swagger-ui .hover-washed-yellow:focus,
+ .swagger-ui .hover-washed-yellow:hover,
+ .swagger-ui .washed-yellow {
+ color: #fbf9ef;
+ }
+
+ .swagger-ui .hover-washed-red:focus,
+ .swagger-ui .hover-washed-red:hover,
+ .swagger-ui .washed-red {
+ color: #f9e7e7;
+ }
+
+ .swagger-ui .color-inherit,
+ .swagger-ui .hover-inherit:focus,
+ .swagger-ui .hover-inherit:hover {
+ color: inherit;
+ }
+
+ .swagger-ui .bg-black-90,
+ .swagger-ui .hover-bg-black-90:focus,
+ .swagger-ui .hover-bg-black-90:hover {
+ background-color: rgba(0, 0, 0, .9);
+ }
+
+ .swagger-ui .bg-black-80,
+ .swagger-ui .hover-bg-black-80:focus,
+ .swagger-ui .hover-bg-black-80:hover {
+ background-color: rgba(0, 0, 0, .8);
+ }
+
+ .swagger-ui .bg-black-70,
+ .swagger-ui .hover-bg-black-70:focus,
+ .swagger-ui .hover-bg-black-70:hover {
+ background-color: rgba(0, 0, 0, .7);
+ }
+
+ .swagger-ui .bg-black-60,
+ .swagger-ui .hover-bg-black-60:focus,
+ .swagger-ui .hover-bg-black-60:hover {
+ background-color: rgba(0, 0, 0, .6);
+ }
+
+ .swagger-ui .bg-black-50,
+ .swagger-ui .hover-bg-black-50:focus,
+ .swagger-ui .hover-bg-black-50:hover {
+ background-color: rgba(0, 0, 0, .5);
+ }
+
+ .swagger-ui .bg-black-40,
+ .swagger-ui .hover-bg-black-40:focus,
+ .swagger-ui .hover-bg-black-40:hover {
+ background-color: rgba(0, 0, 0, .4);
+ }
+
+ .swagger-ui .bg-black-30,
+ .swagger-ui .hover-bg-black-30:focus,
+ .swagger-ui .hover-bg-black-30:hover {
+ background-color: rgba(0, 0, 0, .3);
+ }
+
+ .swagger-ui .bg-black-20,
+ .swagger-ui .hover-bg-black-20:focus,
+ .swagger-ui .hover-bg-black-20:hover {
+ background-color: rgba(0, 0, 0, .2);
+ }
+
+ .swagger-ui .bg-white-90,
+ .swagger-ui .hover-bg-white-90:focus,
+ .swagger-ui .hover-bg-white-90:hover {
+ background-color: rgba(28, 28, 33, .9);
+ }
+
+ .swagger-ui .bg-white-80,
+ .swagger-ui .hover-bg-white-80:focus,
+ .swagger-ui .hover-bg-white-80:hover {
+ background-color: rgba(28, 28, 33, .8);
+ }
+
+ .swagger-ui .bg-white-70,
+ .swagger-ui .hover-bg-white-70:focus,
+ .swagger-ui .hover-bg-white-70:hover {
+ background-color: rgba(28, 28, 33, .7);
+ }
+
+ .swagger-ui .bg-white-60,
+ .swagger-ui .hover-bg-white-60:focus,
+ .swagger-ui .hover-bg-white-60:hover {
+ background-color: rgba(28, 28, 33, .6);
+ }
+
+ .swagger-ui .bg-white-50,
+ .swagger-ui .hover-bg-white-50:focus,
+ .swagger-ui .hover-bg-white-50:hover {
+ background-color: rgba(28, 28, 33, .5);
+ }
+
+ .swagger-ui .bg-white-40,
+ .swagger-ui .hover-bg-white-40:focus,
+ .swagger-ui .hover-bg-white-40:hover {
+ background-color: rgba(28, 28, 33, .4);
+ }
+
+ .swagger-ui .bg-white-30,
+ .swagger-ui .hover-bg-white-30:focus,
+ .swagger-ui .hover-bg-white-30:hover {
+ background-color: rgba(28, 28, 33, .3);
+ }
+
+ .swagger-ui .bg-white-20,
+ .swagger-ui .hover-bg-white-20:focus,
+ .swagger-ui .hover-bg-white-20:hover {
+ background-color: rgba(28, 28, 33, .2);
+ }
+
+ .swagger-ui .bg-black,
+ .swagger-ui .hover-bg-black:focus,
+ .swagger-ui .hover-bg-black:hover {
+ background-color: #000;
+ }
+
+ .swagger-ui .bg-near-black,
+ .swagger-ui .hover-bg-near-black:focus,
+ .swagger-ui .hover-bg-near-black:hover {
+ background-color: #121212;
+ }
+
+ .swagger-ui .bg-dark-gray,
+ .swagger-ui .hover-bg-dark-gray:focus,
+ .swagger-ui .hover-bg-dark-gray:hover {
+ background-color: #333;
+ }
+
+ .swagger-ui .bg-mid-gray,
+ .swagger-ui .hover-bg-mid-gray:focus,
+ .swagger-ui .hover-bg-mid-gray:hover {
+ background-color: #545454;
+ }
+
+ .swagger-ui .bg-gray,
+ .swagger-ui .hover-bg-gray:focus,
+ .swagger-ui .hover-bg-gray:hover {
+ background-color: #787878;
+ }
+
+ .swagger-ui .bg-silver,
+ .swagger-ui .hover-bg-silver:focus,
+ .swagger-ui .hover-bg-silver:hover {
+ background-color: #999;
+ }
+
+ .swagger-ui .bg-white,
+ .swagger-ui .hover-bg-white:focus,
+ .swagger-ui .hover-bg-white:hover {
+ background-color: #1c1c21;
+ }
+
+ .swagger-ui .bg-transparent,
+ .swagger-ui .hover-bg-transparent:focus,
+ .swagger-ui .hover-bg-transparent:hover {
+ background-color: transparent;
+ }
+
+ .swagger-ui .bg-dark-red,
+ .swagger-ui .hover-bg-dark-red:focus,
+ .swagger-ui .hover-bg-dark-red:hover {
+ background-color: #bc2f36;
+ }
+
+ .swagger-ui .bg-red,
+ .swagger-ui .hover-bg-red:focus,
+ .swagger-ui .hover-bg-red:hover {
+ background-color: #c83932;
+ }
+
+ .swagger-ui .bg-light-red,
+ .swagger-ui .hover-bg-light-red:focus,
+ .swagger-ui .hover-bg-light-red:hover {
+ background-color: #ab3c2b;
+ }
+
+ .swagger-ui .bg-orange,
+ .swagger-ui .hover-bg-orange:focus,
+ .swagger-ui .hover-bg-orange:hover {
+ background-color: #cc6e33;
+ }
+
+ .swagger-ui .bg-gold,
+ .swagger-ui .bg-light-yellow,
+ .swagger-ui .bg-washed-yellow,
+ .swagger-ui .bg-yellow,
+ .swagger-ui .hover-bg-gold:focus,
+ .swagger-ui .hover-bg-gold:hover,
+ .swagger-ui .hover-bg-light-yellow:focus,
+ .swagger-ui .hover-bg-light-yellow:hover,
+ .swagger-ui .hover-bg-washed-yellow:focus,
+ .swagger-ui .hover-bg-washed-yellow:hover,
+ .swagger-ui .hover-bg-yellow:focus,
+ .swagger-ui .hover-bg-yellow:hover {
+ background-color: #664b00;
+ }
+
+ .swagger-ui .bg-purple,
+ .swagger-ui .hover-bg-purple:focus,
+ .swagger-ui .hover-bg-purple:hover {
+ background-color: #5e2ca5;
+ }
+
+ .swagger-ui .bg-light-purple,
+ .swagger-ui .hover-bg-light-purple:focus,
+ .swagger-ui .hover-bg-light-purple:hover {
+ background-color: #672caf;
+ }
+
+ .swagger-ui .bg-dark-pink,
+ .swagger-ui .hover-bg-dark-pink:focus,
+ .swagger-ui .hover-bg-dark-pink:hover {
+ background-color: #ab2b81;
+ }
+
+ .swagger-ui .bg-hot-pink,
+ .swagger-ui .hover-bg-hot-pink:focus,
+ .swagger-ui .hover-bg-hot-pink:hover {
+ background-color: #c03086;
+ }
+
+ .swagger-ui .bg-pink,
+ .swagger-ui .hover-bg-pink:focus,
+ .swagger-ui .hover-bg-pink:hover {
+ background-color: #8f2464;
+ }
+
+ .swagger-ui .bg-light-pink,
+ .swagger-ui .hover-bg-light-pink:focus,
+ .swagger-ui .hover-bg-light-pink:hover {
+ background-color: #721d4d;
+ }
+
+ .swagger-ui .bg-dark-green,
+ .swagger-ui .hover-bg-dark-green:focus,
+ .swagger-ui .hover-bg-dark-green:hover {
+ background-color: #1c6e50;
+ }
+
+ .swagger-ui .bg-green,
+ .swagger-ui .hover-bg-green:focus,
+ .swagger-ui .hover-bg-green:hover {
+ background-color: #279b70;
+ }
+
+ .swagger-ui .bg-light-green,
+ .swagger-ui .hover-bg-light-green:focus,
+ .swagger-ui .hover-bg-light-green:hover {
+ background-color: #228762;
+ }
+
+ .swagger-ui .bg-navy,
+ .swagger-ui .hover-bg-navy:focus,
+ .swagger-ui .hover-bg-navy:hover {
+ background-color: #0d1d35;
+ }
+
+ .swagger-ui .bg-dark-blue,
+ .swagger-ui .hover-bg-dark-blue:focus,
+ .swagger-ui .hover-bg-dark-blue:hover {
+ background-color: #20497e;
+ }
+
+ .swagger-ui .bg-blue,
+ .swagger-ui .hover-bg-blue:focus,
+ .swagger-ui .hover-bg-blue:hover {
+ background-color: #4380d0;
+ }
+
+ .swagger-ui .bg-light-blue,
+ .swagger-ui .hover-bg-light-blue:focus,
+ .swagger-ui .hover-bg-light-blue:hover {
+ background-color: #20517e;
+ }
+
+ .swagger-ui .bg-lightest-blue,
+ .swagger-ui .hover-bg-lightest-blue:focus,
+ .swagger-ui .hover-bg-lightest-blue:hover {
+ background-color: #143a52;
+ }
+
+ .swagger-ui .bg-washed-blue,
+ .swagger-ui .hover-bg-washed-blue:focus,
+ .swagger-ui .hover-bg-washed-blue:hover {
+ background-color: #0c312d;
+ }
+
+ .swagger-ui .bg-washed-green,
+ .swagger-ui .hover-bg-washed-green:focus,
+ .swagger-ui .hover-bg-washed-green:hover {
+ background-color: #0f3d2c;
+ }
+
+ .swagger-ui .bg-washed-red,
+ .swagger-ui .hover-bg-washed-red:focus,
+ .swagger-ui .hover-bg-washed-red:hover {
+ background-color: #411010;
+ }
+
+ .swagger-ui .bg-inherit,
+ .swagger-ui .hover-bg-inherit:focus,
+ .swagger-ui .hover-bg-inherit:hover {
+ background-color: inherit;
+ }
+
+ .swagger-ui .shadow-hover {
+ transition: all .5s cubic-bezier(.165, .84, .44, 1) 0s;
+ }
+
+ .swagger-ui .shadow-hover::after {
+ border-radius: inherit;
+ box-shadow: rgba(0, 0, 0, .2) 0 0 16px 2px;
+ content: "";
+ height: 100%;
+ left: 0;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ transition: opacity .5s cubic-bezier(.165, .84, .44, 1) 0s;
+ width: 100%;
+ z-index: -1;
+ }
+
+ .swagger-ui .bg-animate,
+ .swagger-ui .bg-animate:focus,
+ .swagger-ui .bg-animate:hover {
+ transition: background-color .15s ease-in-out 0s;
+ }
+
+ .swagger-ui .nested-links a {
+ color: #99bae6;
+ transition: color .15s ease-in 0s;
+ }
+
+ .swagger-ui .nested-links a:focus,
+ .swagger-ui .nested-links a:hover {
+ color: #a9cbea;
+ transition: color .15s ease-in 0s;
+ }
+
+ .swagger-ui .opblock-tag {
+ border-bottom: 1px solid rgba(58, 64, 80, .3);
+ color: #b5bac9;
+ transition: all .2s ease 0s;
+ }
+
+ .swagger-ui .opblock-tag svg,
+ .swagger-ui section.models h4 svg {
+ transition: all .4s ease 0s;
+ }
+
+ .swagger-ui .opblock {
+ border: 1px solid #000;
+ border-radius: 4px;
+ box-shadow: rgba(0, 0, 0, .19) 0 0 3px;
+ margin: 0 0 15px;
+ }
+
+ .swagger-ui .opblock .tab-header .tab-item.active h4 span::after {
+ background: gray;
+ }
+
+ .swagger-ui .opblock.is-open .opblock-summary {
+ border-bottom: 1px solid #000;
+ }
+
+ .swagger-ui .opblock .opblock-section-header {
+ background: rgba(28, 28, 33, .8);
+ box-shadow: rgba(0, 0, 0, .1) 0 1px 2px;
+ }
+
+ .swagger-ui .opblock .opblock-section-header>label>span {
+ padding: 0 10px 0 0;
+ }
+
+ .swagger-ui .opblock .opblock-summary-method {
+ background: #000;
+ color: #fff;
+ text-shadow: rgba(0, 0, 0, .1) 0 1px 0;
+ }
+
+ .swagger-ui .opblock.opblock-post {
+ background: rgba(72, 203, 144, .1);
+ border-color: #48cb90;
+ }
+
+ .swagger-ui .opblock.opblock-post .opblock-summary-method,
+ .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after {
+ background: #48cb90;
+ }
+
+ .swagger-ui .opblock.opblock-post .opblock-summary {
+ border-color: #48cb90;
+ }
+
+ .swagger-ui .opblock.opblock-put {
+ background: rgba(213, 157, 88, .1);
+ border-color: #d59d58;
+ }
+
+ .swagger-ui .opblock.opblock-put .opblock-summary-method,
+ .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after {
+ background: #d59d58;
+ }
+
+ .swagger-ui .opblock.opblock-put .opblock-summary {
+ border-color: #d59d58;
+ }
+
+ .swagger-ui .opblock.opblock-delete {
+ background: rgba(200, 50, 50, .1);
+ border-color: #c83232;
+ }
+
+ .swagger-ui .opblock.opblock-delete .opblock-summary-method,
+ .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after {
+ background: #c83232;
+ }
+
+ .swagger-ui .opblock.opblock-delete .opblock-summary {
+ border-color: #c83232;
+ }
+
+ .swagger-ui .opblock.opblock-get {
+ background: rgba(42, 105, 167, .1);
+ border-color: #2a69a7;
+ }
+
+ .swagger-ui .opblock.opblock-get .opblock-summary-method,
+ .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after {
+ background: #2a69a7;
+ }
+
+ .swagger-ui .opblock.opblock-get .opblock-summary {
+ border-color: #2a69a7;
+ }
+
+ .swagger-ui .opblock.opblock-patch {
+ background: rgba(92, 214, 188, .1);
+ border-color: #5cd6bc;
+ }
+
+ .swagger-ui .opblock.opblock-patch .opblock-summary-method,
+ .swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after {
+ background: #5cd6bc;
+ }
+
+ .swagger-ui .opblock.opblock-patch .opblock-summary {
+ border-color: #5cd6bc;
+ }
+
+ .swagger-ui .opblock.opblock-head {
+ background: rgba(140, 63, 207, .1);
+ border-color: #8c3fcf;
+ }
+
+ .swagger-ui .opblock.opblock-head .opblock-summary-method,
+ .swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after {
+ background: #8c3fcf;
+ }
+
+ .swagger-ui .opblock.opblock-head .opblock-summary {
+ border-color: #8c3fcf;
+ }
+
+ .swagger-ui .opblock.opblock-options {
+ background: rgba(36, 89, 143, .1);
+ border-color: #24598f;
+ }
+
+ .swagger-ui .opblock.opblock-options .opblock-summary-method,
+ .swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after {
+ background: #24598f;
+ }
+
+ .swagger-ui .opblock.opblock-options .opblock-summary {
+ border-color: #24598f;
+ }
+
+ .swagger-ui .opblock.opblock-deprecated {
+ background: rgba(46, 46, 46, .1);
+ border-color: #2e2e2e;
+ opacity: .6;
+ }
+
+ .swagger-ui .opblock.opblock-deprecated .opblock-summary-method,
+ .swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span::after {
+ background: #2e2e2e;
+ }
+
+ .swagger-ui .opblock.opblock-deprecated .opblock-summary {
+ border-color: #2e2e2e;
+ }
+
+ .swagger-ui .filter .operation-filter-input {
+ border: 2px solid #2b3446;
+ }
+
+ .swagger-ui .tab li:first-of-type::after {
+ background: rgba(0, 0, 0, .2);
+ }
+
+ .swagger-ui .download-contents {
+ background: #7c8192;
+ color: #fff;
+ }
+
+ .swagger-ui .scheme-container {
+ background: #1c1c21;
+ box-shadow: rgba(0, 0, 0, .15) 0 1px 2px 0;
+ }
+
+ .swagger-ui .loading-container .loading::before {
+ animation: 1s linear 0s infinite normal none running rotation, .5s ease 0s 1 normal none running opacity;
+ border-color: rgba(0, 0, 0, .6) rgba(84, 84, 84, .1) rgba(84, 84, 84, .1);
+ }
+
+ .swagger-ui .response-control-media-type--accept-controller select {
+ border-color: #196619;
+ }
+
+ .swagger-ui .response-control-media-type__accept-message {
+ color: #99e699;
+ }
+
+ .swagger-ui .version-pragma__message code {
+ background-color: #3b3b3b;
+ }
+
+ .swagger-ui .btn {
+ background: 0 0;
+ border: 2px solid gray;
+ box-shadow: rgba(0, 0, 0, .1) 0 1px 2px;
+ color: #b5bac9;
+ }
+
+ .swagger-ui .btn:hover {
+ box-shadow: rgba(0, 0, 0, .3) 0 0 5px;
+ }
+
+ .swagger-ui .btn.authorize,
+ .swagger-ui .btn.cancel {
+ background-color: transparent;
+ border-color: #a72a2a;
+ color: #e69999;
+ }
+
+ .swagger-ui .btn.cancel:hover {
+ background-color: #a72a2a;
+ color: #fff;
+ }
+
+ .swagger-ui .btn.authorize {
+ border-color: #48cb90;
+ color: #9ce3c3;
+ }
+
+ .swagger-ui .btn.authorize svg {
+ fill: #9ce3c3;
+ }
+
+ .btn.authorize.unlocked:hover {
+ background-color: #48cb90;
+ color: #fff;
+ }
+
+ .btn.authorize.unlocked:hover svg {
+ fill: #fbfbfb;
+ }
+
+ .swagger-ui .btn.execute {
+ background-color: #5892d5;
+ border-color: #5892d5;
+ color: #fff;
+ }
+
+ .swagger-ui .copy-to-clipboard {
+ background: #7c8192;
+ }
+
+ .swagger-ui .copy-to-clipboard button {
+ background: url("data:image/svg+xml;charset=utf-8, ") 50% center no-repeat;
+ }
+
+ .swagger-ui select {
+ background: url("data:image/svg+xml;charset=utf-8, ") right 10px center/20px no-repeat #212121;
+ background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQuc3ZnIgogICBpZD0ic3ZnNCIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMjAgMjAiPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEwIj4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZGVmcwogICAgIGlkPSJkZWZzOCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOndpbmRvdy15PSItOSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTkiCiAgICAgaW5rc2NhcGU6Y3k9IjEwIgogICAgIGlua3NjYXBlOmN4PSIxMCIKICAgICBpbmtzY2FwZTp6b29tPSI0MS41IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEzLjQxOCA3Ljg1OWEuNjk1LjY5NSAwIDAxLjk3OCAwIC42OC42OCAwIDAxMCAuOTY5bC0zLjkwOCAzLjgzYS42OTcuNjk3IDAgMDEtLjk3OSAwbC0zLjkwOC0zLjgzYS42OC42OCAwIDAxMC0uOTY5LjY5NS42OTUgMCAwMS45NzggMEwxMCAxMWwzLjQxOC0zLjE0MXoiIC8+Cjwvc3ZnPgo=) right 10px center/20px no-repeat #1c1c21;
+ border: 2px solid #41444e;
+ }
+
+ .swagger-ui select[multiple] {
+ background: #212121;
+ }
+
+ .swagger-ui button.invalid,
+ .swagger-ui input[type=email].invalid,
+ .swagger-ui input[type=file].invalid,
+ .swagger-ui input[type=password].invalid,
+ .swagger-ui input[type=search].invalid,
+ .swagger-ui input[type=text].invalid,
+ .swagger-ui select.invalid,
+ .swagger-ui textarea.invalid {
+ background: #390e0e;
+ border-color: #c83232;
+ }
+
+ .swagger-ui input[type=email],
+ .swagger-ui input[type=file],
+ .swagger-ui input[type=password],
+ .swagger-ui input[type=search],
+ .swagger-ui input[type=text],
+ .swagger-ui textarea {
+ background: #1c1c21;
+ border: 1px solid #404040;
+ }
+
+ .swagger-ui textarea {
+ background: rgba(28, 28, 33, .8);
+ color: #b5bac9;
+ }
+
+ .swagger-ui input[disabled],
+ .swagger-ui select[disabled] {
+ background-color: #1f1f1f;
+ color: #bfbfbf;
+ }
+
+ .swagger-ui textarea[disabled] {
+ background-color: #41444e;
+ color: #fff;
+ }
+
+ .swagger-ui select[disabled] {
+ border-color: #878787;
+ }
+
+ .swagger-ui textarea:focus {
+ border: 2px solid #2a69a7;
+ }
+
+ .swagger-ui .checkbox input[type=checkbox]+label>.item {
+ background: #303030;
+ box-shadow: #303030 0 0 0 2px;
+ }
+
+ .swagger-ui .checkbox input[type=checkbox]:checked+label>.item {
+ background: url("data:image/svg+xml;charset=utf-8, ") 50% center no-repeat #303030;
+ }
+
+ .swagger-ui .dialog-ux .backdrop-ux {
+ background: rgba(0, 0, 0, .8);
+ }
+
+ .swagger-ui .dialog-ux .modal-ux {
+ background: #1c1c21;
+ border: 1px solid #2e2e2e;
+ box-shadow: rgba(0, 0, 0, .2) 0 10px 30px 0;
+ }
+
+ .swagger-ui .dialog-ux .modal-ux-header .close-modal {
+ background: 0 0;
+ }
+
+ .swagger-ui .model .deprecated span,
+ .swagger-ui .model .deprecated td {
+ color: #bfbfbf !important;
+ }
+
+ .swagger-ui .model-toggle::after {
+ background: url("data:image/svg+xml;charset=utf-8, ") 50% center/100% no-repeat;
+ }
+
+ .swagger-ui .model-hint {
+ background: rgba(0, 0, 0, .7);
+ color: #ebebeb;
+ }
+
+ .swagger-ui section.models {
+ border: 1px solid rgba(58, 64, 80, .3);
+ }
+
+ .swagger-ui section.models.is-open h4 {
+ border-bottom: 1px solid rgba(58, 64, 80, .3);
+ }
+
+ .swagger-ui section.models .model-container {
+ background: rgba(0, 0, 0, .05);
+ }
+
+ .swagger-ui section.models .model-container:hover {
+ background: rgba(0, 0, 0, .07);
+ }
+
+ .swagger-ui .model-box {
+ background: rgba(0, 0, 0, .1);
+ }
+
+ .swagger-ui .prop-type {
+ color: #aaaad4;
+ }
+
+ .swagger-ui table thead tr td,
+ .swagger-ui table thead tr th {
+ border-bottom: 1px solid rgba(58, 64, 80, .2);
+ color: #b5bac9;
+ }
+
+ .swagger-ui .parameter__name.required::after {
+ color: rgba(230, 153, 153, .6);
+ }
+
+ .swagger-ui .topbar .download-url-wrapper .select-label {
+ color: #f0f0f0;
+ }
+
+ .swagger-ui .topbar .download-url-wrapper .download-url-button {
+ background: #63a040;
+ color: #fff;
+ }
+
+ .swagger-ui .info .title small {
+ background: #7c8492;
+ }
+
+ .swagger-ui .info .title small.version-stamp {
+ background-color: #7a9b27;
+ }
+
+ .swagger-ui .auth-container .errors {
+ background-color: #350d0d;
+ color: #b5bac9;
+ }
+
+ .swagger-ui .errors-wrapper {
+ background: rgba(200, 50, 50, .1);
+ border: 2px solid #c83232;
+ }
+
+ .swagger-ui .markdown code,
+ .swagger-ui .renderedmarkdown code {
+ background: rgba(0, 0, 0, .05);
+ color: #c299e6;
+ }
+
+ .swagger-ui .model-toggle:after {
+ background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQyLnN2ZyIKICAgaWQ9InN2ZzQiCiAgIHZlcnNpb249IjEuMSIKICAgaGVpZ2h0PSIyNCIKICAgd2lkdGg9IjI0Ij4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczgiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9Ii05IgogICAgIGlua3NjYXBlOmN5PSIxMiIKICAgICBpbmtzY2FwZTpjeD0iMTIiCiAgICAgaW5rc2NhcGU6em9vbT0iMzQuNTgzMzMzIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiIgLz4KPC9zdmc+Cg==) 50% no-repeat;
+ }
+
+ /* arrows for each operation and request are now white */
+ .arrow,
+ #large-arrow-up {
+ fill: #fff;
+ }
+
+ #unlocked {
+ fill: #fff;
+ }
+
+ ::-webkit-scrollbar-track {
+ background-color: #646464 !important;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-color: #242424 !important;
+ border: 2px solid #3e4346 !important;
+ }
+
+ ::-webkit-scrollbar-button:vertical:start:decrement {
+ background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
+ background-color: #b6b6b6;
+ }
+
+ ::-webkit-scrollbar-button:vertical:end:increment {
+ background: linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%);
+ background-color: #b6b6b6;
+ }
+
+ ::-webkit-scrollbar-button:horizontal:end:increment {
+ background: linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%);
+ background-color: #b6b6b6;
+ }
+
+ ::-webkit-scrollbar-button:horizontal:start:decrement {
+ background: linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%);
+ background-color: #b6b6b6;
+ }
+
+ ::-webkit-scrollbar-button,
+ ::-webkit-scrollbar-track-piece {
+ background-color: #3e4346 !important;
+ }
+
+ .swagger-ui .black,
+ .swagger-ui .checkbox,
+ .swagger-ui .dark-gray,
+ .swagger-ui .download-url-wrapper .loading,
+ .swagger-ui .errors-wrapper .errors small,
+ .swagger-ui .fallback,
+ .swagger-ui .filter .loading,
+ .swagger-ui .gray,
+ .swagger-ui .hover-black:focus,
+ .swagger-ui .hover-black:hover,
+ .swagger-ui .hover-dark-gray:focus,
+ .swagger-ui .hover-dark-gray:hover,
+ .swagger-ui .hover-gray:focus,
+ .swagger-ui .hover-gray:hover,
+ .swagger-ui .hover-light-silver:focus,
+ .swagger-ui .hover-light-silver:hover,
+ .swagger-ui .hover-mid-gray:focus,
+ .swagger-ui .hover-mid-gray:hover,
+ .swagger-ui .hover-near-black:focus,
+ .swagger-ui .hover-near-black:hover,
+ .swagger-ui .hover-silver:focus,
+ .swagger-ui .hover-silver:hover,
+ .swagger-ui .light-silver,
+ .swagger-ui .markdown pre,
+ .swagger-ui .mid-gray,
+ .swagger-ui .model .property,
+ .swagger-ui .model .property.primitive,
+ .swagger-ui .model-title,
+ .swagger-ui .near-black,
+ .swagger-ui .parameter__extension,
+ .swagger-ui .parameter__in,
+ .swagger-ui .prop-format,
+ .swagger-ui .renderedmarkdown pre,
+ .swagger-ui .response-col_links .response-undocumented,
+ .swagger-ui .response-col_status .response-undocumented,
+ .swagger-ui .silver,
+ .swagger-ui section.models h4,
+ .swagger-ui section.models h5,
+ .swagger-ui span.token-not-formatted,
+ .swagger-ui span.token-string,
+ .swagger-ui table.headers .header-example,
+ .swagger-ui table.model tr.description,
+ .swagger-ui table.model tr.extension {
+ color: #bfbfbf;
+ }
+
+ .swagger-ui .hover-white:focus,
+ .swagger-ui .hover-white:hover,
+ .swagger-ui .info .title small pre,
+ .swagger-ui .topbar a,
+ .swagger-ui .white {
+ color: #fff;
+ }
+
+ .swagger-ui .bg-black-10,
+ .swagger-ui .hover-bg-black-10:focus,
+ .swagger-ui .hover-bg-black-10:hover,
+ .swagger-ui .stripe-dark:nth-child(2n + 1) {
+ background-color: rgba(0, 0, 0, .1);
+ }
+
+ .swagger-ui .bg-white-10,
+ .swagger-ui .hover-bg-white-10:focus,
+ .swagger-ui .hover-bg-white-10:hover,
+ .swagger-ui .stripe-light:nth-child(2n + 1) {
+ background-color: rgba(28, 28, 33, .1);
+ }
+
+ .swagger-ui .bg-light-silver,
+ .swagger-ui .hover-bg-light-silver:focus,
+ .swagger-ui .hover-bg-light-silver:hover,
+ .swagger-ui .striped--light-silver:nth-child(2n + 1) {
+ background-color: #6e6e6e;
+ }
+
+ .swagger-ui .bg-moon-gray,
+ .swagger-ui .hover-bg-moon-gray:focus,
+ .swagger-ui .hover-bg-moon-gray:hover,
+ .swagger-ui .striped--moon-gray:nth-child(2n + 1) {
+ background-color: #4d4d4d;
+ }
+
+ .swagger-ui .bg-light-gray,
+ .swagger-ui .hover-bg-light-gray:focus,
+ .swagger-ui .hover-bg-light-gray:hover,
+ .swagger-ui .striped--light-gray:nth-child(2n + 1) {
+ background-color: #2b2b2b;
+ }
+
+ .swagger-ui .bg-near-white,
+ .swagger-ui .hover-bg-near-white:focus,
+ .swagger-ui .hover-bg-near-white:hover,
+ .swagger-ui .striped--near-white:nth-child(2n + 1) {
+ background-color: #242424;
+ }
+
+ .swagger-ui .opblock-tag:hover,
+ .swagger-ui section.models h4:hover {
+ background: rgba(0, 0, 0, .02);
+ }
+
+ .swagger-ui .checkbox p,
+ .swagger-ui .dialog-ux .modal-ux-content h4,
+ .swagger-ui .dialog-ux .modal-ux-content p,
+ .swagger-ui .dialog-ux .modal-ux-header h3,
+ .swagger-ui .errors-wrapper .errors h4,
+ .swagger-ui .errors-wrapper hgroup h4,
+ .swagger-ui .info .base-url,
+ .swagger-ui .info .title,
+ .swagger-ui .info h1,
+ .swagger-ui .info h2,
+ .swagger-ui .info h3,
+ .swagger-ui .info h4,
+ .swagger-ui .info h5,
+ .swagger-ui .info li,
+ .swagger-ui .info p,
+ .swagger-ui .info table,
+ .swagger-ui .loading-container .loading::after,
+ .swagger-ui .model,
+ .swagger-ui .opblock .opblock-section-header h4,
+ .swagger-ui .opblock .opblock-section-header>label,
+ .swagger-ui .opblock .opblock-summary-description,
+ .swagger-ui .opblock .opblock-summary-operation-id,
+ .swagger-ui .opblock .opblock-summary-path,
+ .swagger-ui .opblock .opblock-summary-path__deprecated,
+ .swagger-ui .opblock-description-wrapper,
+ .swagger-ui .opblock-description-wrapper h4,
+ .swagger-ui .opblock-description-wrapper p,
+ .swagger-ui .opblock-external-docs-wrapper,
+ .swagger-ui .opblock-external-docs-wrapper h4,
+ .swagger-ui .opblock-external-docs-wrapper p,
+ .swagger-ui .opblock-tag small,
+ .swagger-ui .opblock-title_normal,
+ .swagger-ui .opblock-title_normal h4,
+ .swagger-ui .opblock-title_normal p,
+ .swagger-ui .parameter__name,
+ .swagger-ui .parameter__type,
+ .swagger-ui .response-col_links,
+ .swagger-ui .response-col_status,
+ .swagger-ui .responses-inner h4,
+ .swagger-ui .responses-inner h5,
+ .swagger-ui .scheme-container .schemes>label,
+ .swagger-ui .scopes h2,
+ .swagger-ui .servers>label,
+ .swagger-ui .tab li,
+ .swagger-ui label,
+ .swagger-ui select,
+ .swagger-ui table.headers td {
+ color: #b5bac9;
+ }
+
+ .swagger-ui .download-url-wrapper .failed,
+ .swagger-ui .filter .failed,
+ .swagger-ui .model-deprecated-warning,
+ .swagger-ui .parameter__deprecated,
+ .swagger-ui .parameter__name.required span,
+ .swagger-ui table.model tr.property-row .star {
+ color: #e69999;
+ }
+
+ .swagger-ui .opblock-body pre.microlight,
+ .swagger-ui textarea.curl {
+ background: #41444e;
+ border-radius: 4px;
+ color: #fff;
+ }
+
+ .swagger-ui .expand-methods svg,
+ .swagger-ui .expand-methods:hover svg {
+ fill: #bfbfbf;
+ }
+
+ .swagger-ui .auth-container,
+ .swagger-ui .dialog-ux .modal-ux-header {
+ border-bottom: 1px solid #2e2e2e;
+ }
+
+ .swagger-ui .topbar .download-url-wrapper .select-label select,
+ .swagger-ui .topbar .download-url-wrapper input[type=text] {
+ border: 2px solid #63a040;
+ }
+
+ .swagger-ui .info a,
+ .swagger-ui .info a:hover,
+ .swagger-ui .scopes h2 a {
+ color: #99bde6;
+ }
+
+ /* Dark Scrollbar */
+ ::-webkit-scrollbar {
+ width: 14px;
+ height: 14px;
+ }
+
+ ::-webkit-scrollbar-button {
+ background-color: #3e4346 !important;
+ }
+
+ ::-webkit-scrollbar-track {
+ background-color: #646464 !important;
+ }
+
+ ::-webkit-scrollbar-track-piece {
+ background-color: #3e4346 !important;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ height: 50px;
+ background-color: #242424 !important;
+ border: 2px solid #3e4346 !important;
+ }
+
+ ::-webkit-scrollbar-corner {}
+
+ ::-webkit-resizer {}
+
+ ::-webkit-scrollbar-button:vertical:start:decrement {
+ background:
+ linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
+ linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
+ linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
+ background-color: #b6b6b6;
+ }
+
+ ::-webkit-scrollbar-button:vertical:end:increment {
+ background:
+ linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
+ linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
+ linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
+ background-color: #b6b6b6;
+ }
+
+ ::-webkit-scrollbar-button:horizontal:end:increment {
+ background:
+ linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
+ linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
+ linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
+ background-color: #b6b6b6;
+ }
+
+ ::-webkit-scrollbar-button:horizontal:start:decrement {
+ background:
+ linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
+ linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
+ linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
+ background-color: #b6b6b6;
+ }
+}
\ No newline at end of file
diff --git a/server/swagger/index.css b/server/swagger/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..1d346cbc9bee70f22a815e35f8210983c93d26f2
--- /dev/null
+++ b/server/swagger/index.css
@@ -0,0 +1,3 @@
+.schemes.wrapper>div:first-of-type {
+ display: none;
+}
\ No newline at end of file
diff --git a/server/swagger/index.js b/server/swagger/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..12c54cfa5937a02f60c44f46609ee50f206f2205
--- /dev/null
+++ b/server/swagger/index.js
@@ -0,0 +1,28 @@
+function waitForElm(selector) {
+ return new Promise(resolve => {
+ if (document.querySelector(selector)) {
+ return resolve(document.querySelector(selector));
+ }
+
+ const observer = new MutationObserver(mutations => {
+ if (document.querySelector(selector)) {
+ resolve(document.querySelector(selector));
+ observer.disconnect();
+ }
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+ });
+}
+
+// Force change the Swagger logo in the header
+waitForElm('.topbar-wrapper').then((elm) => {
+ if (window.SWAGGER_DOCS_ENV === 'development') {
+ elm.innerHTML = ` `
+ } else {
+ elm.innerHTML = ` `
+ }
+});
\ No newline at end of file
diff --git a/server/swagger/init.js b/server/swagger/init.js
new file mode 100644
index 0000000000000000000000000000000000000000..0562230a7fbb49dca99845d841ff1b95600a96ae
--- /dev/null
+++ b/server/swagger/init.js
@@ -0,0 +1,79 @@
+const swaggerAutogen = require("swagger-autogen")({ openapi: "3.0.0" });
+const fs = require("fs");
+const path = require("path");
+
+const doc = {
+ info: {
+ version: "1.0.0",
+ title: "AnythingLLM Developer API",
+ description:
+ "API endpoints that enable programmatic reading, writing, and updating of your AnythingLLM instance. UI supplied by Swagger.io.",
+ },
+ // Swagger-autogen does not allow us to use relative paths as these will resolve to
+ // http:///api in the openapi.json file, so we need to monkey-patch this post-generation.
+ host: "/api",
+ schemes: ["http"],
+ securityDefinitions: {
+ BearerAuth: {
+ type: "http",
+ scheme: "bearer",
+ bearerFormat: "JWT",
+ },
+ },
+ security: [{ BearerAuth: [] }],
+ definitions: {
+ InvalidAPIKey: {
+ message: "Invalid API Key",
+ },
+ },
+};
+
+const outputFile = path.resolve(__dirname, "./openapi.json");
+const endpointsFiles = [
+ "../endpoints/api/auth/index.js",
+ "../endpoints/api/admin/index.js",
+ "../endpoints/api/document/index.js",
+ "../endpoints/api/workspace/index.js",
+ "../endpoints/api/system/index.js",
+ "../endpoints/api/workspaceThread/index.js",
+ "../endpoints/api/userManagement/index.js",
+ "../endpoints/api/openai/index.js",
+ "../endpoints/api/embed/index.js",
+];
+
+swaggerAutogen(outputFile, endpointsFiles, doc).then(({ data }) => {
+ // Remove Authorization parameters from arguments.
+ for (const path of Object.keys(data.paths)) {
+ if (data.paths[path].hasOwnProperty("get")) {
+ let parameters = data.paths[path].get?.parameters || [];
+ parameters = parameters.filter((arg) => arg.name !== "Authorization");
+ data.paths[path].get.parameters = parameters;
+ }
+
+ if (data.paths[path].hasOwnProperty("post")) {
+ let parameters = data.paths[path].post?.parameters || [];
+ parameters = parameters.filter((arg) => arg.name !== "Authorization");
+ data.paths[path].post.parameters = parameters;
+ }
+
+ if (data.paths[path].hasOwnProperty("delete")) {
+ let parameters = data.paths[path].delete?.parameters || [];
+ parameters = parameters.filter((arg) => arg.name !== "Authorization");
+ data.paths[path].delete.parameters = parameters;
+ }
+ }
+
+ const openApiSpec = {
+ ...data,
+ servers: [
+ {
+ url: "/api",
+ },
+ ],
+ };
+ fs.writeFileSync(outputFile, JSON.stringify(openApiSpec, null, 2), {
+ encoding: "utf-8",
+ flag: "w",
+ });
+ console.log(`Swagger-autogen: \x1b[32mPatched servers.url ✔\x1b[0m`);
+});
diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json
new file mode 100644
index 0000000000000000000000000000000000000000..cdca9e46cae963c7c96fb1bd09b8d1c879f2e992
--- /dev/null
+++ b/server/swagger/openapi.json
@@ -0,0 +1,4105 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "version": "1.0.0",
+ "title": "AnythingLLM Developer API",
+ "description": "API endpoints that enable programmatic reading, writing, and updating of your AnythingLLM instance. UI supplied by Swagger.io."
+ },
+ "servers": [
+ {
+ "url": "/api"
+ }
+ ],
+ "paths": {
+ "/v1/auth": {
+ "get": {
+ "tags": [
+ "Authentication"
+ ],
+ "description": "Verify the attached Authentication header contains a valid API token.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Valid auth token was found.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "authenticated": true
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/is-multi-user-mode": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "isMultiUser": true
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/users": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "users": [
+ {
+ "username": "sample-sam",
+ "role": "default"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/admin/users/new": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Create a new user with username and password. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "user": {
+ "id": 1,
+ "username": "sample-sam",
+ "role": "default"
+ },
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Key pair object that will define the new user to add to the system.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "username": "sample-sam",
+ "password": "hunter2",
+ "role": "default | admin"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/users/{id}": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Update existing user settings. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "id of the user in the database."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Key pair object that will update the found user. All fields are optional and will not update unless specified.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "username": "sample-sam",
+ "password": "hunter2",
+ "role": "default | admin",
+ "suspended": 0
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Delete existing user by id. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "id of the user in the database."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/admin/invites": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "List all existing invitations to instance regardless of status. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "invites": [
+ {
+ "id": 1,
+ "status": "pending",
+ "code": "abc-123",
+ "claimedBy": null
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/admin/invite/new": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Create a new invite code for someone to use to register with instance. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "invite": {
+ "id": 1,
+ "status": "pending",
+ "code": "abc-123"
+ },
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Request body for creation parameters of the invitation",
+ "required": false,
+ "content": {
+ "application/json": {
+ "example": {
+ "workspaceIds": [
+ 1,
+ 2,
+ 45
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/invite/{id}": {
+ "delete": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Deactivates (soft-delete) invite by id. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "id of the invite in the database."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/admin/workspaces/{workspaceId}/users": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Retrieve a list of users with permissions to access the specified workspace.",
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "id of the workspace."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "users": [
+ {
+ "userId": 1,
+ "role": "admin"
+ },
+ {
+ "userId": 2,
+ "role": "member"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/admin/workspaces/{workspaceId}/update-users": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Overwrite workspace permissions to only be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [
+ {
+ "name": "workspaceId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "id of the workspace in the database."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Entire array of user ids who can access the workspace. All fields are optional and will not update unless specified.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "userIds": [
+ 1,
+ 2,
+ 4,
+ 12
+ ]
+ }
+ }
+ }
+ },
+ "deprecated": true
+ }
+ },
+ "/v1/admin/workspaces/{workspaceSlug}/manage-users": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Set workspace permissions to be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [
+ {
+ "name": "workspaceSlug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "slug of the workspace in the database"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null,
+ "users": [
+ {
+ "userId": 1,
+ "username": "main-admin",
+ "role": "admin"
+ },
+ {
+ "userId": 2,
+ "username": "sample-sam",
+ "role": "default"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Array of user ids who will be given access to the target workspace. reset will remove all existing users from the workspace and only add the new users - default false.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "userIds": [
+ 1,
+ 2,
+ 4,
+ 12
+ ],
+ "reset": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/workspace-chats": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "All chats in the system ordered by most recent. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Page offset to show of workspace chats. All fields are optional and will not update unless specified.",
+ "required": false,
+ "content": {
+ "application/json": {
+ "example": {
+ "offset": 2
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/preferences": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "description": "Update multi-user preferences for instance. Methods are disabled until multi user mode is enabled via the UI.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Method denied"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Object with setting key and new value to set. All keys are optional and will not update unless specified.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "support_email": "support@example.com"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/document/upload": {
+ "post": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Upload a new file to AnythingLLM to be parsed and prepared for embedding, with optional metadata.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null,
+ "documents": [
+ {
+ "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "url": "file://Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt",
+ "title": "anythingllm.txt",
+ "docAuthor": "Unknown",
+ "description": "Unknown",
+ "docSource": "a text file uploaded by the user.",
+ "chunkSource": "anythingllm.txt",
+ "published": "1/16/2024, 3:07:00 PM",
+ "wordCount": 93,
+ "token_count_estimate": 115
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "File to be uploaded.",
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "file"
+ ],
+ "properties": {
+ "file": {
+ "type": "string",
+ "format": "binary",
+ "description": "The file to upload"
+ },
+ "addToWorkspaces": {
+ "type": "string",
+ "description": "comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2"
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.",
+ "example": {
+ "title": "Custom Title",
+ "docAuthor": "Author Name",
+ "description": "A brief description",
+ "docSource": "Source of the document"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/document/upload/{folderName}": {
+ "post": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Upload a new file to a specific folder in AnythingLLM to be parsed and prepared for embedding. If the folder does not exist, it will be created.",
+ "parameters": [
+ {
+ "name": "folderName",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Target folder path (defaults to 'custom-documents' if not provided)",
+ "example": "my-folder"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null,
+ "documents": [
+ {
+ "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json",
+ "url": "file://Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt",
+ "title": "anythingllm.txt",
+ "docAuthor": "Unknown",
+ "description": "Unknown",
+ "docSource": "a text file uploaded by the user.",
+ "chunkSource": "anythingllm.txt",
+ "published": "1/16/2024, 3:07:00 PM",
+ "wordCount": 93,
+ "token_count_estimate": 115
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": false,
+ "error": "Document processing API is not online. Document will not be processed automatically."
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "description": "File to be uploaded, with optional metadata.",
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "file"
+ ],
+ "properties": {
+ "file": {
+ "type": "string",
+ "format": "binary",
+ "description": "The file to upload"
+ },
+ "addToWorkspaces": {
+ "type": "string",
+ "description": "comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2"
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.",
+ "example": {
+ "title": "Custom Title",
+ "docAuthor": "Author Name",
+ "description": "A brief description",
+ "docSource": "Source of the document"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/document/upload-link": {
+ "post": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Upload a valid URL for AnythingLLM to scrape and prepare for embedding. Optionally, specify a comma-separated list of workspace slugs to embed the document into post-upload.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null,
+ "documents": [
+ {
+ "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc",
+ "url": "file://useanything_com.html",
+ "title": "useanything_com.html",
+ "docAuthor": "no author found",
+ "description": "No description found.",
+ "docSource": "URL link uploaded by the user.",
+ "chunkSource": "https:anythingllm.com.html",
+ "published": "1/16/2024, 3:46:33 PM",
+ "wordCount": 252,
+ "pageContent": "AnythingLLM is the best....",
+ "token_count_estimate": 447,
+ "location": "custom-documents/url-useanything_com-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Link of web address to be scraped and optionally a comma-separated list of workspace slugs to embed the document into post-upload, and optional metadata.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "link": "https://anythingllm.com",
+ "addToWorkspaces": "workspace1,workspace2",
+ "scraperHeaders": {
+ "Authorization": "Bearer token123",
+ "My-Custom-Header": "value"
+ },
+ "metadata": {
+ "title": "Custom Title",
+ "docAuthor": "Author Name",
+ "description": "A brief description",
+ "docSource": "Source of the document"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/document/raw-text": {
+ "post": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Upload a file by specifying its raw text content and metadata values without having to upload a file.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null,
+ "documents": [
+ {
+ "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc",
+ "url": "file://my-document.txt",
+ "title": "hello-world.txt",
+ "docAuthor": "no author found",
+ "description": "No description found.",
+ "docSource": "My custom description set during upload",
+ "chunkSource": "no chunk source specified",
+ "published": "1/16/2024, 3:46:33 PM",
+ "wordCount": 252,
+ "pageContent": "AnythingLLM is the best....",
+ "token_count_estimate": 447,
+ "location": "custom-documents/raw-my-doc-text-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Text content and metadata of the file to be saved to the system. Use metadata-schema endpoint to get the possible metadata keys",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "textContent": "This is the raw text that will be saved as a document in AnythingLLM.",
+ "addToWorkspaces": "workspace1,workspace2",
+ "metadata": {
+ "title": "This key is required. See in /server/endpoints/api/document/index.js:287",
+ "keyOne": "valueOne",
+ "keyTwo": "valueTwo",
+ "etc": "etc"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/documents": {
+ "get": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "List of all locally-stored documents in instance",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "localFiles": {
+ "name": "documents",
+ "type": "folder",
+ "items": [
+ {
+ "name": "my-stored-document.json",
+ "type": "file",
+ "id": "bb07c334-4dab-4419-9462-9d00065a49a1",
+ "url": "file://my-stored-document.txt",
+ "title": "my-stored-document.txt",
+ "cached": false
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/documents/folder/{folderName}": {
+ "get": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Get all documents stored in a specific folder.",
+ "parameters": [
+ {
+ "name": "folderName",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Name of the folder to retrieve documents from"
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "folder": "custom-documents",
+ "documents": [
+ {
+ "name": "document1.json",
+ "type": "file",
+ "cached": false,
+ "pinnedWorkspaces": [],
+ "watched": false,
+ "more": "data"
+ },
+ {
+ "name": "document2.json",
+ "type": "file",
+ "cached": false,
+ "pinnedWorkspaces": [],
+ "watched": false,
+ "more": "data"
+ }
+ ]
+ }
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/document/accepted-file-types": {
+ "get": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Check available filetypes and MIMEs that can be uploaded.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "types": {
+ "application/mbox": [
+ ".mbox"
+ ],
+ "application/pdf": [
+ ".pdf"
+ ],
+ "application/vnd.oasis.opendocument.text": [
+ ".odt"
+ ],
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
+ ".docx"
+ ],
+ "text/plain": [
+ ".txt",
+ ".md"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/document/metadata-schema": {
+ "get": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Get the known available metadata schema for when doing a raw-text upload and the acceptable type of value for each key.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "schema": {
+ "keyOne": "string | number | nullable",
+ "keyTwo": "string | number | nullable",
+ "specialKey": "number",
+ "title": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/document/{docName}": {
+ "get": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Get a single document by its unique AnythingLLM document name",
+ "parameters": [
+ {
+ "name": "docName",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique document name to find (name in /documents)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "localFiles": {
+ "name": "documents",
+ "type": "folder",
+ "items": [
+ {
+ "name": "my-stored-document.txt-uuid1234.json",
+ "type": "file",
+ "id": "bb07c334-4dab-4419-9462-9d00065a49a1",
+ "url": "file://my-stored-document.txt",
+ "title": "my-stored-document.txt",
+ "cached": false
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/document/create-folder": {
+ "post": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Create a new folder inside the documents storage directory.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "message": null
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Name of the folder to create.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string",
+ "example": {
+ "name": "new-folder"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/document/remove-folder": {
+ "delete": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Remove a folder and all its contents from the documents storage directory.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "message": "Folder removed successfully"
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Name of the folder to remove.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "example": "my-folder"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/document/move-files": {
+ "post": {
+ "tags": [
+ "Documents"
+ ],
+ "description": "Move files within the documents storage directory.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "message": null
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Array of objects containing source and destination paths of files to move.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "files": [
+ {
+ "from": "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json",
+ "to": "folder/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/new": {
+ "post": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Create a new workspace",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "workspace": {
+ "id": 79,
+ "name": "Sample workspace",
+ "slug": "sample-workspace",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null
+ },
+ "message": "Workspace created"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "JSON object containing workspace configuration.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "name": "My New Workspace",
+ "similarityThreshold": 0.7,
+ "openAiTemp": 0.7,
+ "openAiHistory": 20,
+ "openAiPrompt": "Custom prompt for responses",
+ "queryRefusalResponse": "Custom refusal message",
+ "chatMode": "chat",
+ "topN": 4
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspaces": {
+ "get": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "List all current workspaces",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "workspaces": [
+ {
+ "id": 79,
+ "name": "Sample workspace",
+ "slug": "sample-workspace",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "threads": []
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}": {
+ "get": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Get a workspace by its unique slug.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace to find"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "workspace": [
+ {
+ "id": 79,
+ "name": "My workspace",
+ "slug": "my-workspace-123",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "documents": [],
+ "threads": []
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Deletes a workspace by its slug.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace to delete"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/update": {
+ "post": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Update workspace settings by its unique slug.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace to find"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "workspace": {
+ "id": 79,
+ "name": "My workspace",
+ "slug": "my-workspace-123",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "documents": []
+ },
+ "message": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "JSON object containing new settings to update a workspace. All keys are optional and will not update unless provided",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "name": "Updated Workspace Name",
+ "openAiTemp": 0.2,
+ "openAiHistory": 20,
+ "openAiPrompt": "Respond to all inquires and questions in binary - do not respond in any other format."
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/chats": {
+ "get": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Get a workspaces chats regardless of user by its unique slug.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace to find"
+ },
+ {
+ "name": "apiSessionId",
+ "in": "query",
+ "description": "Optional apiSessionId to filter by",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "description": "Optional number of chat messages to return (default: 100)",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "orderBy",
+ "in": "query",
+ "description": "Optional order of chat messages (asc or desc)",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "history": [
+ {
+ "role": "user",
+ "content": "What is AnythingLLM?",
+ "sentAt": 1692851630
+ },
+ {
+ "role": "assistant",
+ "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.",
+ "sources": [
+ {
+ "source": "object about source document and snippets used"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/update-embeddings": {
+ "post": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Add or remove documents from a workspace by its unique slug.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace to find"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "workspace": {
+ "id": 79,
+ "name": "My workspace",
+ "slug": "my-workspace-123",
+ "createdAt": "2023-08-17 00:45:03",
+ "openAiTemp": null,
+ "lastUpdatedAt": "2023-08-17 00:45:03",
+ "openAiHistory": 20,
+ "openAiPrompt": null,
+ "documents": []
+ },
+ "message": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "JSON object of additions and removals of documents to add to update a workspace. The value should be the folder + filename with the exclusions of the top-level documents path.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "adds": [
+ "custom-documents/my-pdf.pdf-hash.json"
+ ],
+ "deletes": [
+ "custom-documents/anythingllm.txt-hash.json"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/update-pin": {
+ "post": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Add or remove pin from a document in a workspace by its unique slug.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace to find"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "message": "Pin status updated successfully"
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Document not found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "JSON object with the document path and pin status to update.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "docPath": "custom-documents/my-pdf.pdf-hash.json",
+ "pinStatus": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/chat": {
+ "post": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Execute a chat with a workspace",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "id": "chat-uuid",
+ "type": "abort | textResponse",
+ "textResponse": "Response to your query",
+ "sources": [
+ {
+ "title": "anythingllm.txt",
+ "chunk": "This is a context chunk used in the answer of the prompt by the LLM,"
+ }
+ ],
+ "close": true,
+ "error": "null | text string of the failure mode."
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Send a prompt to the workspace and the type of conversation (query or chat).Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "message": "What is AnythingLLM?",
+ "mode": "query | chat",
+ "sessionId": "identifier-to-partition-chats-by-external-id",
+ "attachments": [
+ {
+ "name": "image.png",
+ "mime": "image/png",
+ "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ "reset": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/stream-chat": {
+ "post": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Execute a streamable chat with a workspace",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ {
+ "id": "uuid-123",
+ "type": "abort | textResponseChunk",
+ "textResponse": "First chunk",
+ "sources": [],
+ "close": false,
+ "error": "null | text string of the failure mode."
+ },
+ {
+ "id": "uuid-123",
+ "type": "abort | textResponseChunk",
+ "textResponse": "chunk two",
+ "sources": [],
+ "close": false,
+ "error": "null | text string of the failure mode."
+ },
+ {
+ "id": "uuid-123",
+ "type": "abort | textResponseChunk",
+ "textResponse": "final chunk of LLM output!",
+ "sources": [
+ {
+ "title": "anythingllm.txt",
+ "chunk": "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk."
+ }
+ ],
+ "close": true,
+ "error": "null | text string of the failure mode."
+ }
+ ]
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "description": "Send a prompt to the workspace and the type of conversation (query or chat).Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "message": "What is AnythingLLM?",
+ "mode": "query | chat",
+ "sessionId": "identifier-to-partition-chats-by-external-id",
+ "attachments": [
+ {
+ "name": "image.png",
+ "mime": "image/png",
+ "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ "reset": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/vector-search": {
+ "post": {
+ "tags": [
+ "Workspaces"
+ ],
+ "description": "Perform a vector similarity search in a workspace",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace to search in"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "results": [
+ {
+ "id": "5a6bee0a-306c-47fc-942b-8ab9bf3899c4",
+ "text": "Document chunk content...",
+ "metadata": {
+ "url": "file://document.txt",
+ "title": "document.txt",
+ "author": "no author specified",
+ "description": "no description found",
+ "docSource": "post:123456",
+ "chunkSource": "document.txt",
+ "published": "12/1/2024, 11:39:39 AM",
+ "wordCount": 8,
+ "tokenCount": 9
+ },
+ "distance": 0.541887640953064,
+ "score": 0.45811235904693604
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Query to perform vector search with and optional parameters",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "query": "What is the meaning of life?",
+ "topN": 4,
+ "scoreThreshold": 0.75
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/system/env-dump": {
+ "get": {
+ "tags": [
+ "System Settings"
+ ],
+ "description": "Dump all settings to file storage",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/system": {
+ "get": {
+ "tags": [
+ "System Settings"
+ ],
+ "description": "Get all current system settings that are defined.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "settings": {
+ "VectorDB": "pinecone",
+ "PineConeKey": true,
+ "PineConeIndex": "my-pinecone-index",
+ "LLMProvider": "azure",
+ "[KEY_NAME]": "KEY_VALUE"
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/system/vector-count": {
+ "get": {
+ "tags": [
+ "System Settings"
+ ],
+ "description": "Number of all vectors in connected vector database",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "vectorCount": 5450
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/system/update-env": {
+ "post": {
+ "tags": [
+ "System Settings"
+ ],
+ "description": "Update a system setting or preference.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "newValues": {
+ "[ENV_KEY]": "Value"
+ },
+ "error": "error goes here, otherwise null"
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Key pair object that matches a valid setting and value. Get keys from GET /v1/system or refer to codebase.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "VectorDB": "lancedb",
+ "AnotherKey": "updatedValue"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/system/export-chats": {
+ "get": {
+ "tags": [
+ "System Settings"
+ ],
+ "description": "Export all of the chats from the system in a known format. Output depends on the type sent. Will be send with the correct header for the output.",
+ "parameters": [
+ {
+ "name": "type",
+ "in": "query",
+ "description": "Export format jsonl, json, csv, jsonAlpaca",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": [
+ {
+ "role": "user",
+ "content": "What is AnythinglLM?"
+ },
+ {
+ "role": "assistant",
+ "content": "AnythingLLM is a knowledge graph and vector database management system built using NodeJS express server. It provides an interface for handling all interactions, including vectorDB management and LLM (Language Model) interactions."
+ }
+ ]
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/system/remove-documents": {
+ "delete": {
+ "tags": [
+ "System Settings"
+ ],
+ "description": "Permanently remove documents from the system.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Documents removed successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "message": "Documents removed successfully"
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Array of document names to be removed permanently.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "names": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/thread/new": {
+ "post": {
+ "tags": [
+ "Workspace Threads"
+ ],
+ "description": "Create a new workspace thread",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "thread": {
+ "id": 1,
+ "name": "Thread",
+ "slug": "thread-uuid",
+ "user_id": 1,
+ "workspace_id": 1
+ },
+ "message": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Optional userId associated with the thread, thread slug and thread name",
+ "required": false,
+ "content": {
+ "application/json": {
+ "example": {
+ "userId": 1,
+ "name": "Name",
+ "slug": "thread-slug"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/thread/{threadSlug}/update": {
+ "post": {
+ "tags": [
+ "Workspace Threads"
+ ],
+ "description": "Update thread name by its unique slug.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace"
+ },
+ {
+ "name": "threadSlug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of thread"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "thread": {
+ "id": 1,
+ "name": "Updated Thread Name",
+ "slug": "thread-uuid",
+ "user_id": 1,
+ "workspace_id": 1
+ },
+ "message": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "JSON object containing new name to update the thread.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "name": "Updated Thread Name"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/thread/{threadSlug}": {
+ "delete": {
+ "tags": [
+ "Workspace Threads"
+ ],
+ "description": "Delete a workspace thread",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace"
+ },
+ {
+ "name": "threadSlug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of thread"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Thread deleted successfully"
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/thread/{threadSlug}/chats": {
+ "get": {
+ "tags": [
+ "Workspace Threads"
+ ],
+ "description": "Get chats for a workspace thread",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace"
+ },
+ {
+ "name": "threadSlug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of thread"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "history": [
+ {
+ "role": "user",
+ "content": "What is AnythingLLM?",
+ "sentAt": 1692851630
+ },
+ {
+ "role": "assistant",
+ "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.",
+ "sources": [
+ {
+ "source": "object about source document and snippets used"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/thread/{threadSlug}/chat": {
+ "post": {
+ "tags": [
+ "Workspace Threads"
+ ],
+ "description": "Chat with a workspace thread",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace"
+ },
+ {
+ "name": "threadSlug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of thread"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "id": "chat-uuid",
+ "type": "abort | textResponse",
+ "textResponse": "Response to your query",
+ "sources": [
+ {
+ "title": "anythingllm.txt",
+ "chunk": "This is a context chunk used in the answer of the prompt by the LLM."
+ }
+ ],
+ "close": true,
+ "error": "null | text string of the failure mode."
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Send a prompt to the workspace thread and the type of conversation (query or chat).",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "message": "What is AnythingLLM?",
+ "mode": "query | chat",
+ "userId": 1,
+ "attachments": [
+ {
+ "name": "image.png",
+ "mime": "image/png",
+ "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ "reset": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workspace/{slug}/thread/{threadSlug}/stream-chat": {
+ "post": {
+ "tags": [
+ "Workspace Threads"
+ ],
+ "description": "Stream chat with a workspace thread",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of workspace"
+ },
+ {
+ "name": "threadSlug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unique slug of thread"
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ {
+ "id": "uuid-123",
+ "type": "abort | textResponseChunk",
+ "textResponse": "First chunk",
+ "sources": [],
+ "close": false,
+ "error": "null | text string of the failure mode."
+ },
+ {
+ "id": "uuid-123",
+ "type": "abort | textResponseChunk",
+ "textResponse": "chunk two",
+ "sources": [],
+ "close": false,
+ "error": "null | text string of the failure mode."
+ },
+ {
+ "id": "uuid-123",
+ "type": "abort | textResponseChunk",
+ "textResponse": "final chunk of LLM output!",
+ "sources": [
+ {
+ "title": "anythingllm.txt",
+ "chunk": "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk."
+ }
+ ],
+ "close": true,
+ "error": "null | text string of the failure mode."
+ }
+ ]
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "description": "Send a prompt to the workspace thread and the type of conversation (query or chat).",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "message": "What is AnythingLLM?",
+ "mode": "query | chat",
+ "userId": 1,
+ "attachments": [
+ {
+ "name": "image.png",
+ "mime": "image/png",
+ "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+ ],
+ "reset": false
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/users": {
+ "get": {
+ "tags": [
+ "User Management"
+ ],
+ "description": "List all users",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "users": [
+ {
+ "id": 1,
+ "username": "john_doe",
+ "role": "admin"
+ },
+ {
+ "id": 2,
+ "username": "jane_smith",
+ "role": "default"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Permission denied."
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/users/{id}/issue-auth-token": {
+ "get": {
+ "tags": [
+ "User Management"
+ ],
+ "description": "Issue a temporary auth token for a user",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The ID of the user to issue a temporary auth token for"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "token": "1234567890",
+ "loginPath": "/sso/simple?token=1234567890"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Instance is not in Multi-User mode. Permission denied."
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/openai/models": {
+ "get": {
+ "tags": [
+ "OpenAI Compatible Endpoints"
+ ],
+ "description": "Get all available \"models\" which are workspaces you can use for chatting.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "object": "list",
+ "data": [
+ {
+ "id": "model-id-0",
+ "object": "model",
+ "created": 1686935002,
+ "owned_by": "organization-owner"
+ },
+ {
+ "id": "model-id-1",
+ "object": "model",
+ "created": 1686935002,
+ "owned_by": "organization-owner"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/openai/chat/completions": {
+ "post": {
+ "tags": [
+ "OpenAI Compatible Endpoints"
+ ],
+ "description": "Execute a chat with a workspace with OpenAI compatibility. Supports streaming as well. Model must be a workspace slug from /models.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "Send a prompt to the workspace with full use of documents as if sending a chat in AnythingLLM. Only supports some values of OpenAI API. See example below.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "messages": [
+ {
+ "role": "system",
+ "content": "You are a helpful assistant"
+ },
+ {
+ "role": "user",
+ "content": "What is AnythingLLM?"
+ },
+ {
+ "role": "assistant",
+ "content": "AnythingLLM is...."
+ },
+ {
+ "role": "user",
+ "content": "Follow up question..."
+ }
+ ],
+ "model": "sample-workspace",
+ "stream": true,
+ "temperature": 0.7
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/openai/embeddings": {
+ "post": {
+ "tags": [
+ "OpenAI Compatible Endpoints"
+ ],
+ "description": "Get the embeddings of any arbitrary text string. This will use the embedder provider set in the system. Please ensure the token length of each string fits within the context of your embedder model.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "The input string(s) to be embedded. If the text is too long for the embedder model context, it will fail to embed. The vector and associated chunk metadata will be returned in the array order provided",
+ "required": true,
+ "content": {
+ "application/json": {
+ "example": {
+ "input": [
+ "This is my first string to embed",
+ "This is my second string to embed"
+ ],
+ "model": null
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/openai/vector_stores": {
+ "get": {
+ "tags": [
+ "OpenAI Compatible Endpoints"
+ ],
+ "description": "List all the vector database collections connected to AnythingLLM. These are essentially workspaces but return their unique vector db identifier - this is the same as the workspace slug.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "data": [
+ {
+ "id": "slug-here",
+ "object": "vector_store",
+ "name": "My workspace",
+ "file_counts": {
+ "total": 3
+ },
+ "provider": "LanceDB"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/embed": {
+ "get": {
+ "tags": [
+ "Embed"
+ ],
+ "description": "List all active embeds",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "embeds": [
+ {
+ "id": 1,
+ "uuid": "embed-uuid-1",
+ "enabled": true,
+ "chat_mode": "query",
+ "createdAt": "2023-04-01T12:00:00Z",
+ "workspace": {
+ "id": 1,
+ "name": "Workspace 1"
+ },
+ "chat_count": 10
+ },
+ {
+ "id": 2,
+ "uuid": "embed-uuid-2",
+ "enabled": false,
+ "chat_mode": "chat",
+ "createdAt": "2023-04-02T14:30:00Z",
+ "workspace": {
+ "id": 1,
+ "name": "Workspace 1"
+ },
+ "chat_count": 10
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/embed/{embedUuid}/chats": {
+ "get": {
+ "tags": [
+ "Embed"
+ ],
+ "description": "Get all chats for a specific embed",
+ "parameters": [
+ {
+ "name": "embedUuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "UUID of the embed"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "chats": [
+ {
+ "id": 1,
+ "session_id": "session-uuid-1",
+ "prompt": "Hello",
+ "response": "Hi there!",
+ "createdAt": "2023-04-01T12:00:00Z"
+ },
+ {
+ "id": 2,
+ "session_id": "session-uuid-2",
+ "prompt": "How are you?",
+ "response": "I'm doing well, thank you!",
+ "createdAt": "2023-04-02T14:30:00Z"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Embed not found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/embed/{embedUuid}/chats/{sessionUuid}": {
+ "get": {
+ "tags": [
+ "Embed"
+ ],
+ "description": "Get chats for a specific embed and session",
+ "parameters": [
+ {
+ "name": "embedUuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "UUID of the embed"
+ },
+ {
+ "name": "sessionUuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "UUID of the session"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "chats": [
+ {
+ "id": 1,
+ "prompt": "Hello",
+ "response": "Hi there!",
+ "createdAt": "2023-04-01T12:00:00Z"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Embed or session not found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/v1/embed/new": {
+ "post": {
+ "tags": [
+ "Embed"
+ ],
+ "description": "Create a new embed configuration",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "embed": {
+ "id": 1,
+ "uuid": "embed-uuid-1",
+ "enabled": true,
+ "chat_mode": "chat",
+ "allowlist_domains": [
+ "example.com"
+ ],
+ "allow_model_override": false,
+ "allow_temperature_override": false,
+ "allow_prompt_override": false,
+ "max_chats_per_day": 100,
+ "max_chats_per_session": 10,
+ "createdAt": "2023-04-01T12:00:00Z",
+ "workspace_slug": "workspace-slug-1"
+ },
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Workspace not found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "JSON object containing embed configuration details",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "workspace_slug": "workspace-slug-1",
+ "chat_mode": "chat",
+ "allowlist_domains": [
+ "example.com"
+ ],
+ "allow_model_override": false,
+ "allow_temperature_override": false,
+ "allow_prompt_override": false,
+ "max_chats_per_day": 100,
+ "max_chats_per_session": 10
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/embed/{embedUuid}": {
+ "post": {
+ "tags": [
+ "Embed"
+ ],
+ "description": "Update an existing embed configuration",
+ "parameters": [
+ {
+ "name": "embedUuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "UUID of the embed to update"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Embed not found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "description": "JSON object containing embed configuration updates",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "enabled": true,
+ "chat_mode": "chat",
+ "allowlist_domains": [
+ "example.com"
+ ],
+ "allow_model_override": false,
+ "allow_temperature_override": false,
+ "allow_prompt_override": false,
+ "max_chats_per_day": 100,
+ "max_chats_per_session": 10
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Embed"
+ ],
+ "description": "Delete an existing embed configuration",
+ "parameters": [
+ {
+ "name": "embedUuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "UUID of the embed to delete"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "example": {
+ "success": true,
+ "error": null
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "$ref": "#/components/schemas/InvalidAPIKey"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Embed not found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "InvalidAPIKey": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Invalid API Key"
+ }
+ },
+ "xml": {
+ "name": "InvalidAPIKey"
+ }
+ }
+ },
+ "securitySchemes": {
+ "BearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT"
+ }
+ }
+ },
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/server/swagger/utils.js b/server/swagger/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..bd8e3e81c7458cbde767cc9477165bc3f157715e
--- /dev/null
+++ b/server/swagger/utils.js
@@ -0,0 +1,52 @@
+const fs = require('fs');
+const path = require('path');
+const swaggerUi = require('swagger-ui-express');
+
+function faviconUrl() {
+ return process.env.NODE_ENV === "production" ?
+ '/public/favicon.png' :
+ 'http://localhost:3000/public/favicon.png'
+}
+
+function useSwagger(app) {
+ app.use('/api/docs', swaggerUi.serve);
+ const options = {
+ customCss: [
+ fs.readFileSync(path.resolve(__dirname, 'index.css')),
+ fs.readFileSync(path.resolve(__dirname, 'dark-swagger.css'))
+ ].join('\n\n\n'),
+ customSiteTitle: 'AnythingLLM Developer API Documentation',
+ customfavIcon: faviconUrl(),
+ }
+
+ if (process.env.NODE_ENV === "production") {
+ const swaggerDocument = require('./openapi.json');
+ app.get('/api/docs', swaggerUi.setup(
+ swaggerDocument,
+ {
+ ...options,
+ customJsStr: 'window.SWAGGER_DOCS_ENV = "production";\n\n' + fs.readFileSync(path.resolve(__dirname, 'index.js'), 'utf8'),
+ },
+ ));
+ } else {
+ // we regenerate the html page only in development mode to ensure it is up-to-date when the code is hot-reloaded.
+ app.get(
+ "/api/docs",
+ async (_, response) => {
+ // #swagger.ignore = true
+ const swaggerDocument = require('./openapi.json');
+ return response.send(
+ swaggerUi.generateHTML(
+ swaggerDocument,
+ {
+ ...options,
+ customJsStr: 'window.SWAGGER_DOCS_ENV = "development";\n\n' + fs.readFileSync(path.resolve(__dirname, 'index.js'), 'utf8'),
+ }
+ )
+ );
+ }
+ );
+ }
+}
+
+module.exports = { faviconUrl, useSwagger }
\ No newline at end of file
diff --git a/server/utils/AiProviders/anthropic/index.js b/server/utils/AiProviders/anthropic/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..450b376b3e057344b2beeb512c566effbf8efd56
--- /dev/null
+++ b/server/utils/AiProviders/anthropic/index.js
@@ -0,0 +1,278 @@
+const { v4 } = require("uuid");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const { MODEL_MAP } = require("../modelMap");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+class AnthropicLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.ANTHROPIC_API_KEY)
+ throw new Error("No Anthropic API key was set.");
+
+ // Docs: https://www.npmjs.com/package/@anthropic-ai/sdk
+ const AnthropicAI = require("@anthropic-ai/sdk");
+ const anthropic = new AnthropicAI({
+ apiKey: process.env.ANTHROPIC_API_KEY,
+ });
+ this.anthropic = anthropic;
+ this.model =
+ modelPreference ||
+ process.env.ANTHROPIC_MODEL_PREF ||
+ "claude-3-5-sonnet-20241022";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(`Initialized with ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ return MODEL_MAP.get("anthropic", modelName) ?? 100_000;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.get("anthropic", this.model) ?? 100_000;
+ }
+
+ isValidChatCompletionModel(_modelName = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image",
+ source: {
+ type: "base64",
+ media_type: attachment.mime,
+ data: attachment.contentString.split("base64,")[1],
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ try {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.anthropic.messages.create({
+ model: this.model,
+ max_tokens: 4096,
+ system: messages[0].content, // Strip out the system message
+ messages: messages.slice(1), // Pop off the system message
+ temperature: Number(temperature ?? this.defaultTemp),
+ })
+ );
+
+ const promptTokens = result.output.usage.input_tokens;
+ const completionTokens = result.output.usage.output_tokens;
+ return {
+ textResponse: result.output.content[0].text,
+ metrics: {
+ prompt_tokens: promptTokens,
+ completion_tokens: completionTokens,
+ total_tokens: promptTokens + completionTokens,
+ outputTps: completionTokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ } catch (error) {
+ console.log(error);
+ return { textResponse: error, metrics: {} };
+ }
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.anthropic.messages.stream({
+ model: this.model,
+ max_tokens: 4096,
+ system: messages[0].content, // Strip out the system message
+ messages: messages.slice(1), // Pop off the system message
+ temperature: Number(temperature ?? this.defaultTemp),
+ }),
+ messages,
+ false
+ );
+
+ return measuredStreamRequest;
+ }
+
+ /**
+ * Handles the stream response from the Anthropic API.
+ * @param {Object} response - the response object
+ * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream - the stream response from the Anthropic API w/tracking
+ * @param {Object} responseProps - the response properties
+ * @returns {Promise}
+ */
+ handleStream(response, stream, responseProps) {
+ return new Promise((resolve) => {
+ let fullText = "";
+ const { uuid = v4(), sources = [] } = responseProps;
+ let usage = {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ };
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement(usage);
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ stream.on("error", (event) => {
+ const parseErrorMsg = (event) => {
+ const error = event?.error?.error;
+ if (!!error)
+ return `Anthropic Error:${error?.type || "unknown"} ${
+ error?.message || "unknown error."
+ }`;
+ return event.message;
+ };
+
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "abort",
+ textResponse: null,
+ close: true,
+ error: parseErrorMsg(event),
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ });
+
+ stream.on("streamEvent", (message) => {
+ const data = message;
+
+ if (data.type === "message_start")
+ usage.prompt_tokens = data?.message?.usage?.input_tokens;
+ if (data.type === "message_delta")
+ usage.completion_tokens = data?.usage?.output_tokens;
+
+ if (
+ data.type === "content_block_delta" &&
+ data.delta.type === "text_delta"
+ ) {
+ const text = data.delta.text;
+ fullText += text;
+
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: text,
+ close: false,
+ error: false,
+ });
+ }
+
+ if (
+ message.type === "message_stop" ||
+ (data.stop_reason && data.stop_reason === "end_turn")
+ ) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ }
+ });
+ });
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageStringCompressor } = require("../../helpers/chat");
+ const compressedPrompt = await messageStringCompressor(
+ this,
+ promptArgs,
+ rawHistory
+ );
+ return compressedPrompt;
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+}
+
+module.exports = {
+ AnthropicLLM,
+};
diff --git a/server/utils/AiProviders/apipie/index.js b/server/utils/AiProviders/apipie/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..bd794d38ea626ac7039fd414bdebd56522fee2bb
--- /dev/null
+++ b/server/utils/AiProviders/apipie/index.js
@@ -0,0 +1,379 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const { v4: uuidv4 } = require("uuid");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const fs = require("fs");
+const path = require("path");
+const { safeJsonParse } = require("../../http");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+const cacheFolder = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "apipie")
+ : path.resolve(__dirname, `../../../storage/models/apipie`)
+);
+
+class ApiPieLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.APIPIE_LLM_API_KEY)
+ throw new Error("No ApiPie LLM API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.basePath = "https://apipie.ai/v1";
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.APIPIE_LLM_API_KEY ?? null,
+ });
+ this.model =
+ modelPreference ||
+ process.env.APIPIE_LLM_MODEL_PREF ||
+ "openrouter/mistral-7b-instruct";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ this.cacheModelPath = path.resolve(cacheFolder, "models.json");
+ this.cacheAtPath = path.resolve(cacheFolder, ".cached_at");
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis)
+ // from the current date. If it is, then we will refetch the API so that all the models are up
+ // to date.
+ #cacheIsStale() {
+ const MAX_STALE = 6.048e8; // 1 Week in MS
+ if (!fs.existsSync(this.cacheAtPath)) return true;
+ const now = Number(new Date());
+ const timestampMs = Number(fs.readFileSync(this.cacheAtPath));
+ return now - timestampMs > MAX_STALE;
+ }
+
+ // This function fetches the models from the ApiPie API and caches them locally.
+ // We do this because the ApiPie API has a lot of models, and we need to get the proper token context window
+ // for each model and this is a constructor property - so we can really only get it if this cache exists.
+ // We used to have this as a chore, but given there is an API to get the info - this makes little sense.
+ // This might slow down the first request, but we need the proper token context window
+ // for each model and this is a constructor property - so we can really only get it if this cache exists.
+ async #syncModels() {
+ if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale())
+ return false;
+
+ this.log("Model cache is not present or stale. Fetching from ApiPie API.");
+ await fetchApiPieModels();
+ return;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ models() {
+ if (!fs.existsSync(this.cacheModelPath)) return {};
+ return safeJsonParse(
+ fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }),
+ {}
+ );
+ }
+
+ chatModels() {
+ const allModels = this.models();
+ return Object.entries(allModels).reduce(
+ (chatModels, [modelId, modelInfo]) => {
+ // Filter for chat models
+ if (
+ modelInfo.subtype &&
+ (modelInfo.subtype.includes("chat") ||
+ modelInfo.subtype.includes("chatx"))
+ ) {
+ chatModels[modelId] = modelInfo;
+ }
+ return chatModels;
+ },
+ {}
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ const cacheModelPath = path.resolve(cacheFolder, "models.json");
+ const availableModels = fs.existsSync(cacheModelPath)
+ ? safeJsonParse(
+ fs.readFileSync(cacheModelPath, { encoding: "utf-8" }),
+ {}
+ )
+ : {};
+ return availableModels[modelName]?.maxLength || 4096;
+ }
+
+ promptWindowLimit() {
+ const availableModels = this.chatModels();
+ return availableModels[this.model]?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ await this.#syncModels();
+ const availableModels = this.chatModels();
+ return availableModels.hasOwnProperty(model);
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `ApiPie chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps:
+ (result.output.usage?.completion_tokens || 0) / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `ApiPie chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ try {
+ for await (const chunk of stream) {
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+
+ if (token) {
+ fullText += token;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+
+ if (message === undefined || message.finish_reason !== null) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ }
+ } catch (e) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "abort",
+ textResponse: null,
+ close: true,
+ error: e.message,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+async function fetchApiPieModels(providedApiKey = null) {
+ const apiKey = providedApiKey || process.env.APIPIE_LLM_API_KEY || null;
+ return await fetch(`https://apipie.ai/v1/models`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
+ },
+ })
+ .then((res) => res.json())
+ .then(({ data = [] }) => {
+ const models = {};
+ data.forEach((model) => {
+ models[`${model.provider}/${model.model}`] = {
+ id: `${model.provider}/${model.model}`,
+ name: `${model.provider}/${model.model}`,
+ organization: model.provider,
+ subtype: model.subtype,
+ maxLength: model.max_tokens,
+ };
+ });
+
+ // Cache all response information
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ fs.writeFileSync(
+ path.resolve(cacheFolder, "models.json"),
+ JSON.stringify(models),
+ {
+ encoding: "utf-8",
+ }
+ );
+ fs.writeFileSync(
+ path.resolve(cacheFolder, ".cached_at"),
+ String(Number(new Date())),
+ {
+ encoding: "utf-8",
+ }
+ );
+
+ return models;
+ })
+ .catch((e) => {
+ console.error(e);
+ return {};
+ });
+}
+
+module.exports = {
+ ApiPieLLM,
+ fetchApiPieModels,
+};
diff --git a/server/utils/AiProviders/azureOpenAi/index.js b/server/utils/AiProviders/azureOpenAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..d6e58ccfd6d789fa4993d80526ca2a4a003aad0c
--- /dev/null
+++ b/server/utils/AiProviders/azureOpenAi/index.js
@@ -0,0 +1,205 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ formatChatHistory,
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+class AzureOpenAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ const { AzureOpenAI } = require("openai");
+ if (!process.env.AZURE_OPENAI_ENDPOINT)
+ throw new Error("No Azure API endpoint was set.");
+ if (!process.env.AZURE_OPENAI_KEY)
+ throw new Error("No Azure API key was set.");
+
+ this.apiVersion = "2024-12-01-preview";
+ this.openai = new AzureOpenAI({
+ apiKey: process.env.AZURE_OPENAI_KEY,
+ apiVersion: this.apiVersion,
+ endpoint: process.env.AZURE_OPENAI_ENDPOINT,
+ });
+ this.model = modelPreference ?? process.env.OPEN_MODEL_PREF;
+ this.isOTypeModel =
+ process.env.AZURE_OPENAI_MODEL_TYPE === "reasoning" || false;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.#log(
+ `Initialized. Model "${this.model}" @ ${this.promptWindowLimit()} tokens.\nAPI-Version: ${this.apiVersion}.\nModel Type: ${this.isOTypeModel ? "reasoning" : "default"}`
+ );
+ }
+
+ #log(text, ...args) {
+ console.log(`\x1b[32m[AzureOpenAi]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ // Streaming of reasoning models is not supported
+ if (this.isOTypeModel) {
+ this.#log(
+ "Streaming will be disabled. AZURE_OPENAI_MODEL_TYPE is set to 'reasoning'."
+ );
+ return false;
+ }
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ return !!process.env.AZURE_OPENAI_TOKEN_LIMIT
+ ? Number(process.env.AZURE_OPENAI_TOKEN_LIMIT)
+ : 4096;
+ }
+
+ // Sure the user selected a proper value for the token limit
+ // could be any of these https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#gpt-4-models
+ // and if undefined - assume it is the lowest end.
+ promptWindowLimit() {
+ return !!process.env.AZURE_OPENAI_TOKEN_LIMIT
+ ? Number(process.env.AZURE_OPENAI_TOKEN_LIMIT)
+ : 4096;
+ }
+
+ isValidChatCompletionModel(_modelName = "") {
+ // The Azure user names their "models" as deployments and they can be any name
+ // so we rely on the user to put in the correct deployment as only they would
+ // know it.
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ const prompt = {
+ role: this.isOTypeModel ? "user" : "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = [], { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ "No OPEN_MODEL_PREF ENV defined. This must the name of a deployment on your Azure account for an LLM chat model like GPT-3.5."
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions.create({
+ messages,
+ model: this.model,
+ ...(this.isOTypeModel ? {} : { temperature }),
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = [], { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ "No OPEN_MODEL_PREF ENV defined. This must the name of a deployment on your Azure account for an LLM chat model like GPT-3.5."
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ await this.openai.chat.completions.create({
+ messages,
+ model: this.model,
+ ...(this.isOTypeModel ? {} : { temperature }),
+ n: 1,
+ stream: true,
+ }),
+ messages
+ );
+
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ AzureOpenAiLLM,
+};
diff --git a/server/utils/AiProviders/bedrock/index.js b/server/utils/AiProviders/bedrock/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..422ebcc66acf4a952b84c2b86acb6cb937799c0c
--- /dev/null
+++ b/server/utils/AiProviders/bedrock/index.js
@@ -0,0 +1,752 @@
+const {
+ BedrockRuntimeClient,
+ ConverseCommand,
+ ConverseStreamCommand,
+} = require("@aws-sdk/client-bedrock-runtime");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+} = require("../../helpers/chat/responses");
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const { v4: uuidv4 } = require("uuid");
+const {
+ DEFAULT_MAX_OUTPUT_TOKENS,
+ DEFAULT_CONTEXT_WINDOW_TOKENS,
+ SUPPORTED_CONNECTION_METHODS,
+ getImageFormatFromMime,
+ base64ToUint8Array,
+} = require("./utils");
+
+class AWSBedrockLLM {
+ /**
+ * List of Bedrock models observed to not support system prompts when using the Converse API.
+ * @type {string[]}
+ */
+ noSystemPromptModels = [
+ "amazon.titan-text-express-v1",
+ "amazon.titan-text-lite-v1",
+ "cohere.command-text-v14",
+ "cohere.command-light-text-v14",
+ "us.deepseek.r1-v1:0",
+ // Add other models here if identified
+ ];
+
+ /**
+ * Initializes the AWS Bedrock LLM connector.
+ * @param {object | null} [embedder=null] - An optional embedder instance. Defaults to NativeEmbedder.
+ * @param {string | null} [modelPreference=null] - Optional model ID override. Defaults to environment variable.
+ * @throws {Error} If required environment variables are missing or invalid.
+ */
+ constructor(embedder = null, modelPreference = null) {
+ const requiredEnvVars = [
+ ...(this.authMethod !== "iam_role"
+ ? [
+ // required for iam and sessionToken
+ "AWS_BEDROCK_LLM_ACCESS_KEY_ID",
+ "AWS_BEDROCK_LLM_ACCESS_KEY",
+ ]
+ : []),
+ ...(this.authMethod === "sessionToken"
+ ? [
+ // required for sessionToken
+ "AWS_BEDROCK_LLM_SESSION_TOKEN",
+ ]
+ : []),
+ "AWS_BEDROCK_LLM_REGION",
+ "AWS_BEDROCK_LLM_MODEL_PREFERENCE",
+ ];
+
+ // Validate required environment variables
+ for (const envVar of requiredEnvVars) {
+ if (!process.env[envVar])
+ throw new Error(`Required environment variable ${envVar} is not set.`);
+ }
+
+ this.model =
+ modelPreference || process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE;
+
+ const contextWindowLimit = this.promptWindowLimit();
+ this.limits = {
+ history: Math.floor(contextWindowLimit * 0.15),
+ system: Math.floor(contextWindowLimit * 0.15),
+ user: Math.floor(contextWindowLimit * 0.7),
+ };
+
+ this.bedrockClient = new BedrockRuntimeClient({
+ region: process.env.AWS_BEDROCK_LLM_REGION,
+ credentials: this.credentials,
+ });
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.#log(
+ `Initialized with model: ${this.model}. Auth: ${this.authMethod}. Context Window: ${contextWindowLimit}.`
+ );
+ }
+
+ /**
+ * Gets the credentials for the AWS Bedrock LLM based on the authentication method provided.
+ * @returns {object} The credentials object.
+ */
+ get credentials() {
+ switch (this.authMethod) {
+ case "iam": // explicit credentials
+ return {
+ accessKeyId: process.env.AWS_BEDROCK_LLM_ACCESS_KEY_ID,
+ secretAccessKey: process.env.AWS_BEDROCK_LLM_ACCESS_KEY,
+ };
+ case "sessionToken": // Session token is used for temporary credentials
+ return {
+ accessKeyId: process.env.AWS_BEDROCK_LLM_ACCESS_KEY_ID,
+ secretAccessKey: process.env.AWS_BEDROCK_LLM_ACCESS_KEY,
+ sessionToken: process.env.AWS_BEDROCK_LLM_SESSION_TOKEN,
+ };
+ // IAM role is used for long-term credentials implied by system process
+ // is filled by the AWS SDK automatically if we pass in no credentials
+ // returning undefined will allow this to happen
+ case "iam_role":
+ return undefined;
+ default:
+ return undefined;
+ }
+ }
+
+ /**
+ * Gets the configured AWS authentication method ('iam' or 'sessionToken').
+ * Defaults to 'iam' if the environment variable is invalid.
+ * @returns {"iam" | "iam_role" | "sessionToken"} The authentication method.
+ */
+ get authMethod() {
+ const method = process.env.AWS_BEDROCK_LLM_CONNECTION_METHOD || "iam";
+ return SUPPORTED_CONNECTION_METHODS.includes(method) ? method : "iam";
+ }
+
+ /**
+ * Appends context texts to a string with standard formatting.
+ * @param {string[]} contextTexts - An array of context text snippets.
+ * @returns {string} Formatted context string or empty string if no context provided.
+ * @private
+ */
+ #appendContext(contextTexts = []) {
+ if (!contextTexts?.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`)
+ .join("")
+ );
+ }
+
+ /**
+ * Internal logging helper with provider prefix.
+ * @param {string} text - The log message.
+ * @param {...any} args - Additional arguments to log.
+ * @private
+ */
+ #log(text, ...args) {
+ console.log(`\x1b[32m[AWSBedrock]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Internal logging helper with provider prefix for static methods.
+ * @private
+ */
+ static #slog(text, ...args) {
+ console.log(`\x1b[32m[AWSBedrock]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Indicates if the provider supports streaming responses.
+ * @returns {boolean} True.
+ */
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ /**
+ * @static
+ * Gets the total prompt window limit (total context window: input + output) from the environment variable.
+ * This value is used for calculating input limits, NOT for setting the max output tokens in API calls.
+ * @returns {number} The total context window token limit. Defaults to 8191.
+ */
+ static promptWindowLimit() {
+ const limit =
+ process.env.AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT ??
+ DEFAULT_CONTEXT_WINDOW_TOKENS;
+ const numericLimit = Number(limit);
+ if (isNaN(numericLimit) || numericLimit <= 0) {
+ this.#slog(
+ `[AWSBedrock ERROR] Invalid AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT found: "${limitSourceValue}". Must be a positive number - returning default ${DEFAULT_CONTEXT_WINDOW_TOKENS}.`
+ );
+ return DEFAULT_CONTEXT_WINDOW_TOKENS;
+ }
+ return numericLimit;
+ }
+
+ /**
+ * Gets the total prompt window limit (total context window) for the current model instance.
+ * @returns {number} The token limit.
+ */
+ promptWindowLimit() {
+ return AWSBedrockLLM.promptWindowLimit();
+ }
+
+ /**
+ * Gets the maximum number of tokens the model should generate in its response.
+ * Reads from the AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS environment variable or uses a default.
+ * This is distinct from the total context window limit.
+ * @returns {number} The maximum output tokens limit for API calls.
+ */
+ getMaxOutputTokens() {
+ const outputLimitSource = process.env.AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS;
+ if (isNaN(Number(outputLimitSource))) {
+ this.#log(
+ `[AWSBedrock ERROR] Invalid AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS found: "${outputLimitSource}". Must be a positive number - returning default ${DEFAULT_MAX_OUTPUT_TOKENS}.`
+ );
+ return DEFAULT_MAX_OUTPUT_TOKENS;
+ }
+
+ const numericOutputLimit = Number(outputLimitSource);
+ if (numericOutputLimit <= 0) {
+ this.#log(
+ `[AWSBedrock ERROR] Invalid AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS found: "${outputLimitSource}". Must be a greater than 0 - returning default ${DEFAULT_MAX_OUTPUT_TOKENS}.`
+ );
+ return DEFAULT_MAX_OUTPUT_TOKENS;
+ }
+
+ return numericOutputLimit;
+ }
+
+ /** Stubbed method for compatibility with LLM interface. */
+ async isValidChatCompletionModel(_modelName = "") {
+ return true;
+ }
+
+ /**
+ * Validates attachments array and returns a new array with valid attachments.
+ * @param {Array<{contentString: string, mime: string}>} attachments - Array of attachments.
+ * @returns {Array<{image: {format: string, source: {bytes: Uint8Array}}>} Array of valid attachments.
+ * @private
+ */
+ #validateAttachments(attachments = []) {
+ if (!Array.isArray(attachments) || !attachments?.length) return [];
+ const validAttachments = [];
+ for (const attachment of attachments) {
+ if (
+ !attachment ||
+ typeof attachment.mime !== "string" ||
+ typeof attachment.contentString !== "string"
+ ) {
+ this.#log("Skipping invalid attachment object.", attachment);
+ continue;
+ }
+
+ // Strip data URI prefix (e.g., "data:image/png;base64,")
+ const base64Data = attachment.contentString.replace(
+ /^data:image\/\w+;base64,/,
+ ""
+ );
+
+ const format = getImageFormatFromMime(attachment.mime);
+ const attachmentInfo = {
+ valid: format !== null,
+ format,
+ imageBytes: base64ToUint8Array(base64Data),
+ };
+
+ if (!attachmentInfo.valid) {
+ this.#log(
+ `Skipping attachment with unsupported/invalid MIME type: ${attachment.mime}`
+ );
+ continue;
+ }
+
+ validAttachments.push({
+ image: {
+ format: format,
+ source: { bytes: attachmentInfo.imageBytes },
+ },
+ });
+ }
+
+ return validAttachments;
+ }
+
+ /**
+ * Generates the Bedrock Converse API content array for a message,
+ * processing text and formatting valid image attachments.
+ * @param {object} params
+ * @param {string} params.userPrompt - The text part of the message.
+ * @param {Array<{contentString: string, mime: string}>} params.attachments - Array of attachments for the message.
+ * @returns {Array} Array of content blocks (e.g., [{text: "..."}, {image: {...}}]).
+ * @private
+ */
+ #generateContent({ userPrompt = "", attachments = [] }) {
+ const content = [];
+ // Add text block if prompt is not empty
+ if (!!userPrompt?.trim()?.length) content.push({ text: userPrompt });
+
+ // Validate attachments and add valid attachments to content
+ const validAttachments = this.#validateAttachments(attachments);
+ if (validAttachments?.length) content.push(...validAttachments);
+
+ // Ensure content array is never empty (Bedrock requires at least one block)
+ if (content.length === 0) content.push({ text: "" });
+ return content;
+ }
+
+ /**
+ * Constructs the complete message array in the format expected by the Bedrock Converse API.
+ * @param {object} params
+ * @param {string} params.systemPrompt - The system prompt text.
+ * @param {string[]} params.contextTexts - Array of context text snippets.
+ * @param {Array<{role: 'user' | 'assistant', content: string, attachments?: Array<{contentString: string, mime: string}>}>} params.chatHistory - Previous messages.
+ * @param {string} params.userPrompt - The latest user prompt text.
+ * @param {Array<{contentString: string, mime: string}>} params.attachments - Attachments for the latest user prompt.
+ * @returns {Array} The formatted message array for the API call.
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const systemMessageContent = `${systemPrompt}${this.#appendContext(contextTexts)}`;
+ let messages = [];
+
+ // Handle system prompt (either real or simulated)
+ if (this.noSystemPromptModels.includes(this.model)) {
+ if (systemMessageContent.trim().length > 0) {
+ this.#log(
+ `Model ${this.model} doesn't support system prompts; simulating.`
+ );
+ messages.push(
+ {
+ role: "user",
+ content: this.#generateContent({
+ userPrompt: systemMessageContent,
+ }),
+ },
+ { role: "assistant", content: [{ text: "Okay." }] }
+ );
+ }
+ } else if (systemMessageContent.trim().length > 0) {
+ messages.push({
+ role: "system",
+ content: this.#generateContent({ userPrompt: systemMessageContent }),
+ });
+ }
+
+ // Add chat history
+ messages = messages.concat(
+ chatHistory.map((msg) => ({
+ role: msg.role,
+ content: this.#generateContent({
+ userPrompt: msg.content,
+ attachments: Array.isArray(msg.attachments) ? msg.attachments : [],
+ }),
+ }))
+ );
+
+ // Add final user prompt
+ messages.push({
+ role: "user",
+ content: this.#generateContent({
+ userPrompt: userPrompt,
+ attachments: Array.isArray(attachments) ? attachments : [],
+ }),
+ });
+
+ return messages;
+ }
+
+ /**
+ * Parses reasoning steps from the response and prepends them in tags.
+ * @param {object} message - The message object from the Bedrock response.
+ * @returns {string} The text response, potentially with reasoning prepended.
+ * @private
+ */
+ #parseReasoningFromResponse({ content = [] }) {
+ if (!content?.length) return "";
+
+ // Find the text block and grab the text
+ const textBlock = content.find((block) => block.text !== undefined);
+ let textResponse = textBlock?.text || "";
+
+ // Find the reasoning block and grab the reasoning text
+ const reasoningBlock = content.find(
+ (block) => block.reasoningContent?.reasoningText?.text
+ );
+ if (reasoningBlock) {
+ const reasoningText =
+ reasoningBlock.reasoningContent.reasoningText.text.trim();
+ if (!!reasoningText?.length)
+ textResponse = `${reasoningText} ${textResponse}`;
+ }
+ return textResponse;
+ }
+
+ /**
+ * Sends a request for chat completion (non-streaming).
+ * @param {Array | null} messages - Formatted message array from constructPrompt.
+ * @param {object} options - Request options.
+ * @param {number} options.temperature - Sampling temperature.
+ * @returns {Promise} Response object with textResponse and metrics, or null.
+ * @throws {Error} If the API call fails or validation errors occur.
+ */
+ async getChatCompletion(messages = null, { temperature }) {
+ if (!messages?.length)
+ throw new Error(
+ "AWSBedrock::getChatCompletion requires a non-empty messages array."
+ );
+
+ const hasSystem = messages[0]?.role === "system";
+ const systemBlock = hasSystem ? messages[0].content : undefined;
+ const history = hasSystem ? messages.slice(1) : messages;
+ const maxTokensToSend = this.getMaxOutputTokens();
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.bedrockClient
+ .send(
+ new ConverseCommand({
+ modelId: this.model,
+ messages: history,
+ inferenceConfig: {
+ maxTokens: maxTokensToSend,
+ temperature: temperature ?? this.defaultTemp,
+ },
+ system: systemBlock,
+ })
+ )
+ .catch((e) => {
+ this.#log(
+ `Bedrock Converse API Error (getChatCompletion): ${e.message}`,
+ e
+ );
+ if (
+ e.name === "ValidationException" &&
+ e.message.includes("maximum tokens")
+ ) {
+ throw new Error(
+ `AWSBedrock::getChatCompletion failed. Model ${this.model} rejected maxTokens value of ${maxTokensToSend}. Check model documentation for its maximum output token limit and set AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS if needed. Original error: ${e.message}`
+ );
+ }
+ throw new Error(`AWSBedrock::getChatCompletion failed. ${e.message}`);
+ }),
+ messages,
+ false
+ );
+
+ const response = result.output;
+ if (!response?.output?.message) {
+ this.#log(
+ "Bedrock response missing expected output.message structure.",
+ response
+ );
+ return null;
+ }
+
+ const latencyMs = response?.metrics?.latencyMs;
+ const outputTokens = response?.usage?.outputTokens;
+ const outputTps =
+ latencyMs > 0 && outputTokens ? outputTokens / (latencyMs / 1000) : 0;
+
+ return {
+ textResponse: this.#parseReasoningFromResponse(response.output.message),
+ metrics: {
+ prompt_tokens: response?.usage?.inputTokens ?? 0,
+ completion_tokens: outputTokens ?? 0,
+ total_tokens: response?.usage?.totalTokens ?? 0,
+ outputTps: outputTps,
+ duration: result.duration,
+ },
+ };
+ }
+
+ /**
+ * Sends a request for streaming chat completion.
+ * @param {Array | null} messages - Formatted message array from constructPrompt.
+ * @param {object} options - Request options.
+ * @param {number} [options.temperature] - Sampling temperature.
+ * @returns {Promise} The monitored stream object.
+ * @throws {Error} If the API call setup fails or validation errors occur.
+ */
+ async streamGetChatCompletion(messages = null, { temperature }) {
+ if (!Array.isArray(messages) || messages.length === 0) {
+ throw new Error(
+ "AWSBedrock::streamGetChatCompletion requires a non-empty messages array."
+ );
+ }
+
+ const hasSystem = messages[0]?.role === "system";
+ const systemBlock = hasSystem ? messages[0].content : undefined;
+ const history = hasSystem ? messages.slice(1) : messages;
+ const maxTokensToSend = this.getMaxOutputTokens();
+
+ try {
+ // Attempt to initiate the stream
+ const stream = await this.bedrockClient.send(
+ new ConverseStreamCommand({
+ modelId: this.model,
+ messages: history,
+ inferenceConfig: {
+ maxTokens: maxTokensToSend,
+ temperature: temperature ?? this.defaultTemp,
+ },
+ system: systemBlock,
+ })
+ );
+
+ // If successful, wrap the stream with performance monitoring
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ stream,
+ messages,
+ false // Indicate it's not a function call measurement
+ );
+ return measuredStreamRequest;
+ } catch (e) {
+ // Catch errors during the initial .send() call (e.g., validation errors)
+ this.#log(
+ `Bedrock Converse API Error (streamGetChatCompletion setup): ${e.message}`,
+ e
+ );
+ if (
+ e.name === "ValidationException" &&
+ e.message.includes("maximum tokens")
+ ) {
+ throw new Error(
+ `AWSBedrock::streamGetChatCompletion failed during setup. Model ${this.model} rejected maxTokens value of ${maxTokensToSend}. Check model documentation for its maximum output token limit and set AWS_BEDROCK_LLM_MAX_OUTPUT_TOKENS if needed. Original error: ${e.message}`
+ );
+ }
+
+ throw new Error(
+ `AWSBedrock::streamGetChatCompletion failed during setup. ${e.message}`
+ );
+ }
+ }
+
+ /**
+ * Handles the stream response from the AWS Bedrock API ConverseStreamCommand.
+ * Parses chunks, handles reasoning tags, and estimates token usage if not provided.
+ * @param {object} response - The HTTP response object to write chunks to.
+ * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream - The monitored stream object from streamGetChatCompletion.
+ * @param {object} responseProps - Additional properties for the response chunks.
+ * @param {string} responseProps.uuid - Unique ID for the response.
+ * @param {Array} responseProps.sources - Source documents used (if any).
+ * @returns {Promise} A promise that resolves with the complete text response when the stream ends.
+ */
+ handleStream(response, stream, responseProps) {
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+ let hasUsageMetrics = false;
+ let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let reasoningText = "";
+
+ // Abort handler for client closing connection
+ const handleAbort = () => {
+ this.#log(`Client closed connection for stream ${uuid}. Aborting.`);
+ stream?.endMeasurement(usage); // Finalize metrics
+ clientAbortedHandler(resolve, fullText); // Resolve with partial text
+ };
+ response.on("close", handleAbort);
+
+ try {
+ // Process stream chunks
+ for await (const chunk of stream.stream) {
+ if (!chunk) {
+ this.#log("Stream returned null/undefined chunk.");
+ continue;
+ }
+ const action = Object.keys(chunk)[0];
+
+ switch (action) {
+ case "metadata": // Contains usage metrics at the end
+ if (chunk.metadata?.usage) {
+ hasUsageMetrics = true;
+ usage = {
+ // Overwrite with final metrics
+ prompt_tokens: chunk.metadata.usage.inputTokens ?? 0,
+ completion_tokens: chunk.metadata.usage.outputTokens ?? 0,
+ total_tokens: chunk.metadata.usage.totalTokens ?? 0,
+ };
+ }
+ break;
+ case "contentBlockDelta": {
+ // Contains text or reasoning deltas
+ const delta = chunk.contentBlockDelta?.delta;
+ if (!delta) break;
+ const token = delta.text;
+ const reasoningToken = delta.reasoningContent?.text;
+
+ if (reasoningToken) {
+ // Handle reasoning text
+ if (reasoningText.length === 0) {
+ // Start of reasoning block
+ const startTag = "";
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: startTag + reasoningToken,
+ close: false,
+ error: false,
+ });
+ reasoningText += startTag + reasoningToken;
+ } else {
+ // Continuation of reasoning block
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: reasoningToken,
+ close: false,
+ error: false,
+ });
+ reasoningText += reasoningToken;
+ }
+ } else if (token) {
+ // Handle regular text
+ if (reasoningText.length > 0) {
+ // If reasoning was just output, close the tag
+ const endTag = " ";
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: endTag,
+ close: false,
+ error: false,
+ });
+ fullText += reasoningText + endTag; // Add completed reasoning to final text
+ reasoningText = ""; // Reset reasoning buffer
+ }
+ fullText += token; // Append regular text
+ if (!hasUsageMetrics) usage.completion_tokens++; // Estimate usage if no metrics yet
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+ break;
+ }
+ case "messageStop": // End of message event
+ if (chunk.messageStop?.usage) {
+ // Check for final metrics here too
+ hasUsageMetrics = true;
+ usage = {
+ // Overwrite with final metrics if available
+ prompt_tokens:
+ chunk.messageStop.usage.inputTokens ?? usage.prompt_tokens,
+ completion_tokens:
+ chunk.messageStop.usage.outputTokens ??
+ usage.completion_tokens,
+ total_tokens:
+ chunk.messageStop.usage.totalTokens ?? usage.total_tokens,
+ };
+ }
+ // Ensure reasoning tag is closed if message stops mid-reasoning
+ if (reasoningText.length > 0) {
+ const endTag = " ";
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: endTag,
+ close: false,
+ error: false,
+ });
+ fullText += reasoningText + endTag;
+ reasoningText = "";
+ }
+ break;
+ // Ignore other event types for now
+ case "messageStart":
+ case "contentBlockStart":
+ case "contentBlockStop":
+ break;
+ default:
+ this.#log(`Unhandled stream action: ${action}`, chunk);
+ }
+ } // End for await loop
+
+ // Final cleanup for reasoning tag in case stream ended abruptly
+ if (reasoningText.length > 0 && !fullText.endsWith("")) {
+ const endTag = "";
+ if (!response.writableEnded) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: endTag,
+ close: false,
+ error: false,
+ });
+ }
+ fullText += reasoningText + endTag;
+ }
+
+ // Send final closing chunk to signal end of stream
+ if (!response.writableEnded) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ }
+ } catch (error) {
+ // Handle errors during stream processing
+ this.#log(
+ `\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${error.message}`,
+ error
+ );
+ if (response && !response.writableEnded) {
+ writeResponseChunk(response, {
+ uuid,
+ type: "abort",
+ textResponse: null,
+ sources,
+ close: true,
+ error: `AWSBedrock:streaming - error. ${
+ error?.message ?? "Unknown error"
+ }`,
+ });
+ }
+ } finally {
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText); // Resolve with the accumulated text
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ AWSBedrockLLM,
+};
diff --git a/server/utils/AiProviders/bedrock/utils.js b/server/utils/AiProviders/bedrock/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..b217052a606df68f3722c3ad40fa7273bb1f63e5
--- /dev/null
+++ b/server/utils/AiProviders/bedrock/utils.js
@@ -0,0 +1,67 @@
+/** @typedef {'jpeg' | 'png' | 'gif' | 'webp'} */
+const SUPPORTED_BEDROCK_IMAGE_FORMATS = ["jpeg", "png", "gif", "webp"];
+
+/** @type {number} */
+const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
+
+/** @type {number} */
+const DEFAULT_CONTEXT_WINDOW_TOKENS = 8191;
+
+/** @type {'iam' | 'iam_role' | 'sessionToken'} */
+const SUPPORTED_CONNECTION_METHODS = ["iam", "iam_role", "sessionToken"];
+
+/**
+ * Parses a MIME type string (e.g., "image/jpeg") to extract and validate the image format
+ * supported by Bedrock Converse. Handles 'image/jpg' as 'jpeg'.
+ * @param {string | null | undefined} mimeType - The MIME type string.
+ * @returns {string | null} The validated image format (e.g., "jpeg") or null if invalid/unsupported.
+ */
+function getImageFormatFromMime(mimeType = "") {
+ if (!mimeType) return null;
+ const parts = mimeType.toLowerCase().split("/");
+ if (parts?.[0] !== "image") return null;
+ let format = parts?.[1];
+ if (!format) return null;
+
+ // Remap jpg to jpeg
+ switch (format) {
+ case "jpg":
+ format = "jpeg";
+ break;
+ default:
+ break;
+ }
+
+ if (!SUPPORTED_BEDROCK_IMAGE_FORMATS.includes(format)) return null;
+ return format;
+}
+
+/**
+ * Decodes a pure base64 string (without data URI prefix) into a Uint8Array using the atob method.
+ * This approach matches the technique previously used by Langchain's implementation.
+ * @param {string} base64String - The pure base64 encoded data.
+ * @returns {Uint8Array | null} The resulting byte array or null on decoding error.
+ */
+function base64ToUint8Array(base64String) {
+ try {
+ const binaryString = atob(base64String);
+ const len = binaryString.length;
+ const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i);
+ return bytes;
+ } catch (e) {
+ console.error(
+ `[AWSBedrock] Error decoding base64 string with atob: ${e.message}`
+ );
+ return null;
+ }
+}
+
+module.exports = {
+ SUPPORTED_CONNECTION_METHODS,
+ SUPPORTED_BEDROCK_IMAGE_FORMATS,
+ DEFAULT_MAX_OUTPUT_TOKENS,
+ DEFAULT_CONTEXT_WINDOW_TOKENS,
+ getImageFormatFromMime,
+ base64ToUint8Array,
+};
diff --git a/server/utils/AiProviders/cohere/index.js b/server/utils/AiProviders/cohere/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..a6647f5b658536af70c70a1abccb233a6b25c44c
--- /dev/null
+++ b/server/utils/AiProviders/cohere/index.js
@@ -0,0 +1,256 @@
+const { v4 } = require("uuid");
+const { writeResponseChunk } = require("../../helpers/chat/responses");
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const { MODEL_MAP } = require("../modelMap");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+class CohereLLM {
+ constructor(embedder = null) {
+ const { CohereClient } = require("cohere-ai");
+ if (!process.env.COHERE_API_KEY)
+ throw new Error("No Cohere API key was set.");
+
+ const cohere = new CohereClient({
+ token: process.env.COHERE_API_KEY,
+ });
+
+ this.cohere = cohere;
+ this.model = process.env.COHERE_MODEL_PREF;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ #convertChatHistoryCohere(chatHistory = []) {
+ let cohereHistory = [];
+ chatHistory.forEach((message) => {
+ switch (message.role) {
+ case "system":
+ cohereHistory.push({ role: "SYSTEM", message: message.content });
+ break;
+ case "user":
+ cohereHistory.push({ role: "USER", message: message.content });
+ break;
+ case "assistant":
+ cohereHistory.push({ role: "CHATBOT", message: message.content });
+ break;
+ }
+ });
+
+ return cohereHistory;
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ return MODEL_MAP.get("cohere", modelName) ?? 4_096;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.get("cohere", this.model) ?? 4_096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ const validModels = [
+ "command-r",
+ "command-r-plus",
+ "command",
+ "command-light",
+ "command-nightly",
+ "command-light-nightly",
+ ];
+ return validModels.includes(model);
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [prompt, ...chatHistory, { role: "user", content: userPrompt }];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Cohere chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const message = messages[messages.length - 1].content; // Get the last message
+ const cohereHistory = this.#convertChatHistoryCohere(messages.slice(0, -1)); // Remove the last message and convert to Cohere
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.cohere.chat({
+ model: this.model,
+ message: message,
+ chatHistory: cohereHistory,
+ temperature,
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("text") ||
+ result.output.text.length === 0
+ )
+ return null;
+
+ const promptTokens = result.output.meta?.tokens?.inputTokens || 0;
+ const completionTokens = result.output.meta?.tokens?.outputTokens || 0;
+ return {
+ textResponse: result.output.text,
+ metrics: {
+ prompt_tokens: promptTokens,
+ completion_tokens: completionTokens,
+ total_tokens: promptTokens + completionTokens,
+ outputTps: completionTokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Cohere chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const message = messages[messages.length - 1].content; // Get the last message
+ const cohereHistory = this.#convertChatHistoryCohere(messages.slice(0, -1)); // Remove the last message and convert to Cohere
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.cohere.chatStream({
+ model: this.model,
+ message: message,
+ chatHistory: cohereHistory,
+ temperature,
+ }),
+ messages,
+ false
+ );
+
+ return measuredStreamRequest;
+ }
+
+ /**
+ * Handles the stream response from the Cohere API.
+ * @param {Object} response - the response object
+ * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream - the stream response from the Cohere API w/tracking
+ * @param {Object} responseProps - the response properties
+ * @returns {Promise}
+ */
+ async handleStream(response, stream, responseProps) {
+ return new Promise(async (resolve) => {
+ const { uuid = v4(), sources = [] } = responseProps;
+ let fullText = "";
+ let usage = {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ };
+
+ const handleAbort = () => {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "abort",
+ textResponse: fullText,
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream.endMeasurement(usage);
+ resolve(fullText);
+ };
+ response.on("close", handleAbort);
+
+ try {
+ for await (const chat of stream) {
+ if (chat.eventType === "stream-end") {
+ const usageMetrics = chat?.response?.meta?.tokens || {};
+ usage.prompt_tokens = usageMetrics.inputTokens || 0;
+ usage.completion_tokens = usageMetrics.outputTokens || 0;
+ }
+
+ if (chat.eventType === "text-generation") {
+ const text = chat.text;
+ fullText += text;
+
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: text,
+ close: false,
+ error: false,
+ });
+ }
+ }
+
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream.endMeasurement(usage);
+ resolve(fullText);
+ } catch (error) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "abort",
+ textResponse: null,
+ close: true,
+ error: error.message,
+ });
+ response.removeListener("close", handleAbort);
+ stream.endMeasurement(usage);
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ CohereLLM,
+};
diff --git a/server/utils/AiProviders/cometapi/constants.js b/server/utils/AiProviders/cometapi/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..2d7a32da4cc5d76c1b19d4cbb0ec53486b0a58fa
--- /dev/null
+++ b/server/utils/AiProviders/cometapi/constants.js
@@ -0,0 +1,39 @@
+// TODO: When CometAPI's model list is upgraded, this operation needs to be removed
+// Model filtering patterns from cometapi.md that are not supported by AnythingLLM
+module.exports.COMETAPI_IGNORE_PATTERNS = [
+ // Image generation models
+ "dall-e",
+ "dalle",
+ "midjourney",
+ "mj_",
+ "stable-diffusion",
+ "sd-",
+ "flux-",
+ "playground-v",
+ "ideogram",
+ "recraft-",
+ "black-forest-labs",
+ "/recraft-v3",
+ "recraftv3",
+ "stability-ai/",
+ "sdxl",
+ // Audio generation models
+ "suno_",
+ "tts",
+ "whisper",
+ // Video generation models
+ "runway",
+ "luma_",
+ "luma-",
+ "veo",
+ "kling_",
+ "minimax_video",
+ "hunyuan-t1",
+ // Utility models
+ "embedding",
+ "search-gpts",
+ "files_retrieve",
+ "moderation",
+ // Deepl
+ "deepl",
+];
diff --git a/server/utils/AiProviders/cometapi/index.js b/server/utils/AiProviders/cometapi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..fca0b0cc50a4102fc57371a5ebbc1d899b5f77b7
--- /dev/null
+++ b/server/utils/AiProviders/cometapi/index.js
@@ -0,0 +1,438 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const { v4: uuidv4 } = require("uuid");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const fs = require("fs");
+const path = require("path");
+const { safeJsonParse } = require("../../http");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const { COMETAPI_IGNORE_PATTERNS } = require("./constants");
+const cacheFolder = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "cometapi")
+ : path.resolve(__dirname, `../../../storage/models/cometapi`)
+);
+
+class CometApiLLM {
+ defaultTimeout = 3_000;
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.COMETAPI_LLM_API_KEY)
+ throw new Error("No CometAPI API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.basePath = "https://api.cometapi.com/v1";
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.COMETAPI_LLM_API_KEY ?? null,
+ defaultHeaders: {
+ "HTTP-Referer": "https://anythingllm.com",
+ "X-CometAPI-Source": "anythingllm",
+ },
+ });
+ this.model =
+ modelPreference || process.env.COMETAPI_LLM_MODEL_PREF || "gpt-5-mini";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.timeout = this.#parseTimeout();
+
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ this.cacheModelPath = path.resolve(cacheFolder, "models.json");
+ this.cacheAtPath = path.resolve(cacheFolder, ".cached_at");
+
+ this.log(`Loaded with model: ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * CometAPI has various models that never return `finish_reasons` and thus leave the stream open
+ * which causes issues in subsequent messages. This timeout value forces us to close the stream after
+ * x milliseconds. This is a configurable value via the COMETAPI_LLM_TIMEOUT_MS value
+ * @returns {number} The timeout value in milliseconds (default: 3_000)
+ */
+ #parseTimeout() {
+ this.log(
+ `CometAPI timeout is set to ${process.env.COMETAPI_LLM_TIMEOUT_MS ?? this.defaultTimeout}ms`
+ );
+ if (isNaN(Number(process.env.COMETAPI_LLM_TIMEOUT_MS)))
+ return this.defaultTimeout;
+ const setValue = Number(process.env.COMETAPI_LLM_TIMEOUT_MS);
+ if (setValue < 500) return 500;
+ return setValue;
+ }
+
+ // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis)
+ // from the current date. If it is, then we will refetch the API so that all the models are up
+ // to date.
+ #cacheIsStale() {
+ const MAX_STALE = 6.048e8; // 1 Week in MS
+ if (!fs.existsSync(this.cacheAtPath)) return true;
+ const now = Number(new Date());
+ const timestampMs = Number(fs.readFileSync(this.cacheAtPath));
+ return now - timestampMs > MAX_STALE;
+ }
+
+ // The CometAPI model API has a lot of models, so we cache this locally in the directory
+ // as if the cache directory JSON file is stale or does not exist we will fetch from API and store it.
+ // This might slow down the first request, but we need the proper token context window
+ // for each model and this is a constructor property - so we can really only get it if this cache exists.
+ // We used to have this as a chore, but given there is an API to get the info - this makes little sense.
+ async #syncModels() {
+ if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale())
+ return false;
+
+ this.log(
+ "Model cache is not present or stale. Fetching from CometAPI API."
+ );
+ await fetchCometApiModels();
+ return;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ models() {
+ if (!fs.existsSync(this.cacheModelPath)) return {};
+ return safeJsonParse(
+ fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }),
+ {}
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ const cacheModelPath = path.resolve(cacheFolder, "models.json");
+ const availableModels = fs.existsSync(cacheModelPath)
+ ? safeJsonParse(
+ fs.readFileSync(cacheModelPath, { encoding: "utf-8" }),
+ {}
+ )
+ : {};
+ return availableModels[modelName]?.maxLength || 4096;
+ }
+
+ promptWindowLimit() {
+ const availableModels = this.models();
+ return availableModels[this.model]?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ await this.#syncModels();
+ const availableModels = this.models();
+ return availableModels.hasOwnProperty(model);
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `CometAPI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `CometAPI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ /**
+ * Handles the default stream response for a chat.
+ * @param {import("express").Response} response
+ * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream
+ * @param {Object} responseProps
+ * @returns {Promise}
+ */
+ handleStream(response, stream, responseProps) {
+ const timeoutThresholdMs = this.timeout;
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let lastChunkTime = null; // null when first token is still not received.
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ // NOTICE: Not all CometAPI models will return a stop reason
+ // which keeps the connection open and so the model never finalizes the stream
+ // like the traditional OpenAI response schema does. So in the case the response stream
+ // never reaches a formal close state we maintain an interval timer that if we go >=timeoutThresholdMs with
+ // no new chunks then we kill the stream and assume it to be complete. CometAPI is quite fast
+ // so this threshold should permit most responses, but we can adjust `timeoutThresholdMs` if
+ // we find it is too aggressive.
+ const timeoutCheck = setInterval(() => {
+ if (lastChunkTime === null) return;
+
+ const now = Number(new Date());
+ const diffMs = now - lastChunkTime;
+ if (diffMs >= timeoutThresholdMs) {
+ this.log(
+ `CometAPI stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.`
+ );
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ clearInterval(timeoutCheck);
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ }, 500);
+
+ try {
+ for await (const chunk of stream) {
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+ lastChunkTime = Number(new Date());
+
+ if (token) {
+ fullText += token;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+
+ if (message.finish_reason !== null) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ }
+ } catch (e) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "abort",
+ textResponse: null,
+ close: true,
+ error: e.message,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+/**
+ * Fetches available models from CometAPI and filters out non-chat models
+ * Based on cometapi.md specifications
+ */
+async function fetchCometApiModels() {
+ return await fetch(`https://api.cometapi.com/v1/models`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.COMETAPI_LLM_API_KEY}`,
+ },
+ })
+ .then((res) => res.json())
+ .then(({ data = [] }) => {
+ const models = {};
+
+ // Filter out non-chat models using patterns from cometapi.md
+ const chatModels = data.filter((model) => {
+ const modelId = model.id.toLowerCase();
+ return !COMETAPI_IGNORE_PATTERNS.some((pattern) =>
+ modelId.includes(pattern.toLowerCase())
+ );
+ });
+
+ chatModels.forEach((model) => {
+ models[model.id] = {
+ id: model.id,
+ name: model.id, // CometAPI has limited model info according to cometapi.md
+ organization:
+ model.id.split("/")[0] || model.id.split("-")[0] || "CometAPI",
+ maxLength: model.context_length || 4096, // Conservative default
+ };
+ });
+
+ // Cache all response information
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ fs.writeFileSync(
+ path.resolve(cacheFolder, "models.json"),
+ JSON.stringify(models),
+ {
+ encoding: "utf-8",
+ }
+ );
+ fs.writeFileSync(
+ path.resolve(cacheFolder, ".cached_at"),
+ String(Number(new Date())),
+ {
+ encoding: "utf-8",
+ }
+ );
+ return models;
+ })
+ .catch((e) => {
+ console.error("Error fetching CometAPI models:", e);
+ return {};
+ });
+}
+
+module.exports = {
+ CometApiLLM,
+ fetchCometApiModels,
+};
diff --git a/server/utils/AiProviders/deepseek/index.js b/server/utils/AiProviders/deepseek/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..4b5b334bfe048939be528efac1955b5c8dfe3860
--- /dev/null
+++ b/server/utils/AiProviders/deepseek/index.js
@@ -0,0 +1,311 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const { v4: uuidv4 } = require("uuid");
+const { MODEL_MAP } = require("../modelMap");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+} = require("../../helpers/chat/responses");
+
+class DeepSeekLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.DEEPSEEK_API_KEY)
+ throw new Error("No DeepSeek API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+
+ this.openai = new OpenAIApi({
+ apiKey: process.env.DEEPSEEK_API_KEY,
+ baseURL: "https://api.deepseek.com/v1",
+ });
+ this.model =
+ modelPreference || process.env.DEEPSEEK_MODEL_PREF || "deepseek-chat";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(
+ `Initialized ${this.model} with context window ${this.promptWindowLimit()}`
+ );
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ return MODEL_MAP.get("deepseek", modelName) ?? 8192;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.get("deepseek", this.model) ?? 8192;
+ }
+
+ async isValidChatCompletionModel(modelName = "") {
+ const models = await this.openai.models.list().catch(() => ({ data: [] }));
+ return models.data.some((model) => model.id === modelName);
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [prompt, ...chatHistory, { role: "user", content: userPrompt }];
+ }
+
+ /**
+ * Parses and prepends reasoning from the response and returns the full text response.
+ * @param {Object} response
+ * @returns {string}
+ */
+ #parseReasoningFromResponse({ message }) {
+ let textResponse = message?.content;
+ if (
+ !!message?.reasoning_content &&
+ message.reasoning_content.trim().length > 0
+ )
+ textResponse = `${message.reasoning_content} ${textResponse}`;
+ return textResponse;
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `DeepSeek chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result?.output?.hasOwnProperty("choices") ||
+ result?.output?.choices?.length === 0
+ )
+ throw new Error(
+ `Invalid response body returned from DeepSeek: ${JSON.stringify(result.output)}`
+ );
+
+ return {
+ textResponse: this.#parseReasoningFromResponse(result.output.choices[0]),
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `DeepSeek chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages,
+ false
+ );
+
+ return measuredStreamRequest;
+ }
+
+ // TODO: This is a copy of the generic handleStream function in responses.js
+ // to specifically handle the DeepSeek reasoning model `reasoning_content` field.
+ // When or if ever possible, we should refactor this to be in the generic function.
+ handleStream(response, stream, responseProps) {
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+ let hasUsageMetrics = false;
+ let usage = {
+ completion_tokens: 0,
+ };
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let reasoningText = "";
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement(usage);
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ try {
+ for await (const chunk of stream) {
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+ const reasoningToken = message?.delta?.reasoning_content;
+
+ if (
+ chunk.hasOwnProperty("usage") && // exists
+ !!chunk.usage && // is not null
+ Object.values(chunk.usage).length > 0 // has values
+ ) {
+ if (chunk.usage.hasOwnProperty("prompt_tokens")) {
+ usage.prompt_tokens = Number(chunk.usage.prompt_tokens);
+ }
+
+ if (chunk.usage.hasOwnProperty("completion_tokens")) {
+ hasUsageMetrics = true; // to stop estimating counter
+ usage.completion_tokens = Number(chunk.usage.completion_tokens);
+ }
+ }
+
+ // Reasoning models will always return the reasoning text before the token text.
+ if (reasoningToken) {
+ // If the reasoning text is empty (''), we need to initialize it
+ // and send the first chunk of reasoning text.
+ if (reasoningText.length === 0) {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: `${reasoningToken}`,
+ close: false,
+ error: false,
+ });
+ reasoningText += `${reasoningToken}`;
+ continue;
+ } else {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: reasoningToken,
+ close: false,
+ error: false,
+ });
+ reasoningText += reasoningToken;
+ }
+ }
+
+ // If the reasoning text is not empty, but the reasoning token is empty
+ // and the token text is not empty we need to close the reasoning text and begin sending the token text.
+ if (!!reasoningText && !reasoningToken && token) {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: ` `,
+ close: false,
+ error: false,
+ });
+ fullText += `${reasoningText} `;
+ reasoningText = "";
+ }
+
+ if (token) {
+ fullText += token;
+ // If we never saw a usage metric, we can estimate them by number of completion chunks
+ if (!hasUsageMetrics) usage.completion_tokens++;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+
+ // LocalAi returns '' and others return null on chunks - the last chunk is not "" or null.
+ // Either way, the key `finish_reason` must be present to determine ending chunk.
+ if (
+ message?.hasOwnProperty("finish_reason") && // Got valid message and it is an object with finish_reason
+ message.finish_reason !== "" &&
+ message.finish_reason !== null
+ ) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ break; // Break streaming when a valid finish_reason is first encountered
+ }
+ }
+ } catch (e) {
+ console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`);
+ writeResponseChunk(response, {
+ uuid,
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ stream?.endMeasurement(usage);
+ resolve(fullText); // Return what we currently have - if anything.
+ }
+ });
+ }
+
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ DeepSeekLLM,
+};
diff --git a/server/utils/AiProviders/dellProAiStudio/index.js b/server/utils/AiProviders/dellProAiStudio/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..2cca4c8bf66595a6c8a5cf1260afcb7848abcbb3
--- /dev/null
+++ b/server/utils/AiProviders/dellProAiStudio/index.js
@@ -0,0 +1,210 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+// hybrid of openAi LLM chat completion for Dell Pro AI Studio
+class DellProAiStudioLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.DPAIS_LLM_BASE_PATH)
+ throw new Error("No Dell Pro AI Studio Base Path was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.dpais = new OpenAIApi({
+ baseURL: DellProAiStudioLLM.parseBasePath(),
+ apiKey: null,
+ });
+
+ this.model = modelPreference || process.env.DPAIS_LLM_MODEL_PREF;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(
+ `Dell Pro AI Studio LLM initialized with ${this.model}. ctx: ${this.promptWindowLimit()}`
+ );
+ }
+
+ /**
+ * Parse the base path for the Dell Pro AI Studio API
+ * so we can use it for inference requests
+ * @param {string} providedBasePath
+ * @returns {string}
+ */
+ static parseBasePath(providedBasePath = process.env.DPAIS_LLM_BASE_PATH) {
+ try {
+ const baseURL = new URL(providedBasePath);
+ const basePath = `${baseURL.origin}/v1/openai`;
+ return basePath;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.DPAIS_LLM_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No Dell Pro AI Studio token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.DPAIS_LLM_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No Dell Pro AI Studio token context limit was set.");
+ return Number(limit);
+ }
+
+ async isValidChatCompletionModel(_ = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) return userPrompt;
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ _attachments = [], // not used for Dell Pro AI Studio - `attachments` passed in is ignored
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, _attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ `Dell Pro AI Studio chat: ${this.model} is not valid or defined model for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.dpais.chat.completions.create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps: result.output.usage?.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ `Dell Pro AI Studio chat: ${this.model} is not valid or defined model for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.dpais.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ DellProAiStudioLLM,
+};
diff --git a/server/utils/AiProviders/fireworksAi/index.js b/server/utils/AiProviders/fireworksAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..97e5a079011fa9e16270ad11c0058e0f515d1daf
--- /dev/null
+++ b/server/utils/AiProviders/fireworksAi/index.js
@@ -0,0 +1,157 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+
+function fireworksAiModels() {
+ const { MODELS } = require("./models.js");
+ return MODELS || {};
+}
+
+class FireworksAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.FIREWORKS_AI_LLM_API_KEY)
+ throw new Error("No FireworksAI API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.fireworks.ai/inference/v1",
+ apiKey: process.env.FIREWORKS_AI_LLM_API_KEY ?? null,
+ });
+ this.model = modelPreference || process.env.FIREWORKS_AI_LLM_MODEL_PREF;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = !embedder ? new NativeEmbedder() : embedder;
+ this.defaultTemp = 0.7;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ allModelInformation() {
+ return fireworksAiModels();
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ const availableModels = fireworksAiModels();
+ return availableModels[modelName]?.maxLength || 4096;
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const availableModels = this.allModelInformation();
+ return availableModels[this.model]?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ const availableModels = this.allModelInformation();
+ return availableModels.hasOwnProperty(model);
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [prompt, ...chatHistory, { role: "user", content: userPrompt }];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `FireworksAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions.create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `FireworksAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages,
+ false
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ FireworksAiLLM,
+ fireworksAiModels,
+};
diff --git a/server/utils/AiProviders/fireworksAi/models.js b/server/utils/AiProviders/fireworksAi/models.js
new file mode 100644
index 0000000000000000000000000000000000000000..2d059088199e36088ef791f3dc8b1faf3ad56295
--- /dev/null
+++ b/server/utils/AiProviders/fireworksAi/models.js
@@ -0,0 +1,124 @@
+const MODELS = {
+ "accounts/fireworks/models/llama-v3p2-3b-instruct": {
+ id: "accounts/fireworks/models/llama-v3p2-3b-instruct",
+ organization: "Meta",
+ name: "Llama 3.2 3B Instruct",
+ maxLength: 131072,
+ },
+ "accounts/fireworks/models/llama-v3p2-1b-instruct": {
+ id: "accounts/fireworks/models/llama-v3p2-1b-instruct",
+ organization: "Meta",
+ name: "Llama 3.2 1B Instruct",
+ maxLength: 131072,
+ },
+ "accounts/fireworks/models/llama-v3p1-405b-instruct": {
+ id: "accounts/fireworks/models/llama-v3p1-405b-instruct",
+ organization: "Meta",
+ name: "Llama 3.1 405B Instruct",
+ maxLength: 131072,
+ },
+ "accounts/fireworks/models/llama-v3p1-70b-instruct": {
+ id: "accounts/fireworks/models/llama-v3p1-70b-instruct",
+ organization: "Meta",
+ name: "Llama 3.1 70B Instruct",
+ maxLength: 131072,
+ },
+ "accounts/fireworks/models/llama-v3p1-8b-instruct": {
+ id: "accounts/fireworks/models/llama-v3p1-8b-instruct",
+ organization: "Meta",
+ name: "Llama 3.1 8B Instruct",
+ maxLength: 131072,
+ },
+ "accounts/fireworks/models/llama-v3-70b-instruct": {
+ id: "accounts/fireworks/models/llama-v3-70b-instruct",
+ organization: "Meta",
+ name: "Llama 3 70B Instruct",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/mixtral-8x22b-instruct": {
+ id: "accounts/fireworks/models/mixtral-8x22b-instruct",
+ organization: "mistralai",
+ name: "Mixtral MoE 8x22B Instruct",
+ maxLength: 65536,
+ },
+ "accounts/fireworks/models/mixtral-8x7b-instruct": {
+ id: "accounts/fireworks/models/mixtral-8x7b-instruct",
+ organization: "mistralai",
+ name: "Mixtral MoE 8x7B Instruct",
+ maxLength: 32768,
+ },
+ "accounts/fireworks/models/firefunction-v2": {
+ id: "accounts/fireworks/models/firefunction-v2",
+ organization: "Fireworks AI",
+ name: "Firefunction V2",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/firefunction-v1": {
+ id: "accounts/fireworks/models/firefunction-v1",
+ organization: "Fireworks AI",
+ name: "FireFunction V1",
+ maxLength: 32768,
+ },
+ "accounts/fireworks/models/gemma2-9b-it": {
+ id: "accounts/fireworks/models/gemma2-9b-it",
+ organization: "Google",
+ name: "Gemma 2 9B Instruct",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/llama-v3-70b-instruct-hf": {
+ id: "accounts/fireworks/models/llama-v3-70b-instruct-hf",
+ organization: "Hugging Face",
+ name: "Llama 3 70B Instruct (HF version)",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/llama-v3-8b-instruct": {
+ id: "accounts/fireworks/models/llama-v3-8b-instruct",
+ organization: "Hugging Face",
+ name: "Llama 3 8B Instruct",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/llama-v3-8b-instruct-hf": {
+ id: "accounts/fireworks/models/llama-v3-8b-instruct-hf",
+ organization: "Hugging Face",
+ name: "Llama 3 8B Instruct (HF version)",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/mixtral-8x7b-instruct-hf": {
+ id: "accounts/fireworks/models/mixtral-8x7b-instruct-hf",
+ organization: "Hugging Face",
+ name: "Mixtral MoE 8x7B Instruct (HF version)",
+ maxLength: 32768,
+ },
+ "accounts/fireworks/models/mythomax-l2-13b": {
+ id: "accounts/fireworks/models/mythomax-l2-13b",
+ organization: "Gryphe",
+ name: "MythoMax L2 13b",
+ maxLength: 4096,
+ },
+ "accounts/fireworks/models/phi-3-vision-128k-instruct": {
+ id: "accounts/fireworks/models/phi-3-vision-128k-instruct",
+ organization: "Microsoft",
+ name: "Phi 3.5 Vision Instruct",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/starcoder-16b": {
+ id: "accounts/fireworks/models/starcoder-16b",
+ organization: "BigCode",
+ name: "StarCoder 15.5B",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/starcoder-7b": {
+ id: "accounts/fireworks/models/starcoder-7b",
+ organization: "BigCode",
+ name: "StarCoder 7B",
+ maxLength: 8192,
+ },
+ "accounts/fireworks/models/yi-01-ai/yi-large": {
+ id: "accounts/fireworks/models/yi-01-ai/yi-large",
+ organization: "01.AI",
+ name: "Yi-Large",
+ maxLength: 32768,
+ },
+};
+
+module.exports.MODELS = MODELS;
diff --git a/server/utils/AiProviders/fireworksAi/scripts/.gitignore b/server/utils/AiProviders/fireworksAi/scripts/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..94a2dd146a22340832c88013e9fe92663bb9f2cc
--- /dev/null
+++ b/server/utils/AiProviders/fireworksAi/scripts/.gitignore
@@ -0,0 +1 @@
+*.json
\ No newline at end of file
diff --git a/server/utils/AiProviders/fireworksAi/scripts/chat_models.txt b/server/utils/AiProviders/fireworksAi/scripts/chat_models.txt
new file mode 100644
index 0000000000000000000000000000000000000000..1b1a8360ea8562f6786e0b7d06976a6a6e64e04d
--- /dev/null
+++ b/server/utils/AiProviders/fireworksAi/scripts/chat_models.txt
@@ -0,0 +1,22 @@
+| Organization | Model Name | Model String for API | Context length |
+|--------------|------------|----------------------|----------------|
+| Meta | Llama 3.2 3B Instruct | accounts/fireworks/models/llama-v3p2-3b-instruct | 131072 |
+| Meta | Llama 3.2 1B Instruct | accounts/fireworks/models/llama-v3p2-1b-instruct | 131072 |
+| Meta | Llama 3.1 405B Instruct | accounts/fireworks/models/llama-v3p1-405b-instruct | 131072 |
+| Meta | Llama 3.1 70B Instruct | accounts/fireworks/models/llama-v3p1-70b-instruct | 131072 |
+| Meta | Llama 3.1 8B Instruct | accounts/fireworks/models/llama-v3p1-8b-instruct | 131072 |
+| Meta | Llama 3 70B Instruct | accounts/fireworks/models/llama-v3-70b-instruct | 8192 |
+| mistralai | Mixtral MoE 8x22B Instruct | accounts/fireworks/models/mixtral-8x22b-instruct | 65536 |
+| mistralai | Mixtral MoE 8x7B Instruct | accounts/fireworks/models/mixtral-8x7b-instruct | 32768 |
+| Fireworks AI | Firefunction V2 | accounts/fireworks/models/firefunction-v2 | 8192 |
+| Fireworks AI | FireFunction V1 | accounts/fireworks/models/firefunction-v1 | 32768 |
+| Google | Gemma 2 9B Instruct | accounts/fireworks/models/gemma2-9b-it | 8192 |
+| Hugging Face | Llama 3 70B Instruct (HF version) | accounts/fireworks/models/llama-v3-70b-instruct-hf | 8192 |
+| Hugging Face | Llama 3 8B Instruct | accounts/fireworks/models/llama-v3-8b-instruct | 8192 |
+| Hugging Face | Llama 3 8B Instruct (HF version) | accounts/fireworks/models/llama-v3-8b-instruct-hf | 8192 |
+| Hugging Face | Mixtral MoE 8x7B Instruct (HF version) | accounts/fireworks/models/mixtral-8x7b-instruct-hf | 32768 |
+| Gryphe | MythoMax L2 13b | accounts/fireworks/models/mythomax-l2-13b | 4096 |
+| Microsoft | Phi 3.5 Vision Instruct | accounts/fireworks/models/phi-3-vision-128k-instruct | 8192 |
+| BigCode | StarCoder 15.5B | accounts/fireworks/models/starcoder-16b | 8192 |
+| BigCode | StarCoder 7B | accounts/fireworks/models/starcoder-7b | 8192 |
+| 01.AI | Yi-Large | accounts/fireworks/models/yi-01-ai/yi-large | 32768 |
\ No newline at end of file
diff --git a/server/utils/AiProviders/fireworksAi/scripts/parse.mjs b/server/utils/AiProviders/fireworksAi/scripts/parse.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..7ac325df5729e6fbfca8b0bae7fbf6481cf0dcbc
--- /dev/null
+++ b/server/utils/AiProviders/fireworksAi/scripts/parse.mjs
@@ -0,0 +1,46 @@
+// Fireworks AI does not provide a simple REST API to get models,
+// so we have a table which we copy from their documentation
+// at https://fireworks.ai/models that we can
+// then parse and get all models from in a format that makes sense
+// Why this does not exist is so bizarre, but whatever.
+
+// To run, cd into this directory and run `node parse.mjs`
+// copy outputs into the export in ../models.js
+
+// Update the date below if you run this again because Fireworks AI added new models.
+
+// Last Collected: Sep 27, 2024
+// NOTE: Only managed to collect 20 out of ~100 models!
+// https://fireworks.ai/models lists almost 100 chat language models.
+// If you want to add models, please manually add them to chat_models.txt...
+// ... I tried to write a script to grab them all but gave up after a few hours...
+
+import fs from "fs";
+
+function parseChatModels() {
+ const fixed = {};
+ const tableString = fs.readFileSync("chat_models.txt", { encoding: "utf-8" });
+ const rows = tableString.split("\n").slice(2);
+
+ rows.forEach((row) => {
+ const [provider, name, id, maxLength] = row.split("|").slice(1, -1);
+ const data = {
+ provider: provider.trim(),
+ name: name.trim(),
+ id: id.trim(),
+ maxLength: Number(maxLength.trim()),
+ };
+
+ fixed[data.id] = {
+ id: data.id,
+ organization: data.provider,
+ name: data.name,
+ maxLength: data.maxLength,
+ };
+ });
+
+ fs.writeFileSync("chat_models.json", JSON.stringify(fixed, null, 2), "utf-8");
+ return fixed;
+}
+
+parseChatModels();
diff --git a/server/utils/AiProviders/gemini/defaultModels.js b/server/utils/AiProviders/gemini/defaultModels.js
new file mode 100644
index 0000000000000000000000000000000000000000..4a52dc99c6553bd0179b0cfde55910c23254e997
--- /dev/null
+++ b/server/utils/AiProviders/gemini/defaultModels.js
@@ -0,0 +1,74 @@
+const { MODEL_MAP } = require("../modelMap");
+
+const stableModels = [
+ // %STABLE_MODELS% - updated 2025-05-13T23:13:58.920Z
+ "gemini-1.5-pro-001",
+ "gemini-1.5-pro-002",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash-001",
+ "gemini-1.5-flash",
+ "gemini-1.5-flash-002",
+ "gemini-1.5-flash-8b",
+ "gemini-1.5-flash-8b-001",
+ "gemini-2.0-flash",
+ "gemini-2.0-flash-001",
+ "gemini-2.0-flash-lite-001",
+ "gemini-2.0-flash-lite",
+ "gemini-2.0-flash-preview-image-generation",
+ // %EOC_STABLE_MODELS%
+];
+
+// There are some models that are only available in the v1beta API
+// and some models that are only available in the v1 API
+// generally, v1beta models have `exp` in the name, but not always
+// so we check for both against a static list as well via API.
+const v1BetaModels = [
+ // %V1BETA_MODELS% - updated 2025-05-13T23:13:58.920Z
+ "gemini-1.5-pro-latest",
+ "gemini-1.5-flash-latest",
+ "gemini-1.5-flash-8b-latest",
+ "gemini-1.5-flash-8b-exp-0827",
+ "gemini-1.5-flash-8b-exp-0924",
+ "gemini-2.5-pro-exp-03-25",
+ "gemini-2.5-pro-preview-03-25",
+ "gemini-2.5-flash-preview-04-17",
+ "gemini-2.5-flash-preview-04-17-thinking",
+ "gemini-2.5-pro-preview-05-06",
+ "gemini-2.0-flash-exp",
+ "gemini-2.0-flash-exp-image-generation",
+ "gemini-2.0-flash-lite-preview-02-05",
+ "gemini-2.0-flash-lite-preview",
+ "gemini-2.0-pro-exp",
+ "gemini-2.0-pro-exp-02-05",
+ "gemini-exp-1206",
+ "gemini-2.0-flash-thinking-exp-01-21",
+ "gemini-2.0-flash-thinking-exp",
+ "gemini-2.0-flash-thinking-exp-1219",
+ "learnlm-1.5-pro-experimental",
+ "learnlm-2.0-flash-experimental",
+ "gemma-3-1b-it",
+ "gemma-3-4b-it",
+ "gemma-3-12b-it",
+ "gemma-3-27b-it",
+ // %EOC_V1BETA_MODELS%
+];
+
+const defaultGeminiModels = () => [
+ ...stableModels.map((model) => ({
+ id: model,
+ name: model,
+ contextWindow: MODEL_MAP.get("gemini", model),
+ experimental: false,
+ })),
+ ...v1BetaModels.map((model) => ({
+ id: model,
+ name: model,
+ contextWindow: MODEL_MAP.get("gemini", model),
+ experimental: true,
+ })),
+];
+
+module.exports = {
+ defaultGeminiModels,
+ v1BetaModels,
+};
diff --git a/server/utils/AiProviders/gemini/index.js b/server/utils/AiProviders/gemini/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..c7d39bc782a65ece54e8426b171c8eccde2a070f
--- /dev/null
+++ b/server/utils/AiProviders/gemini/index.js
@@ -0,0 +1,448 @@
+const fs = require("fs");
+const path = require("path");
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ formatChatHistory,
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+const { MODEL_MAP } = require("../modelMap");
+const { defaultGeminiModels, v1BetaModels } = require("./defaultModels");
+const { safeJsonParse } = require("../../http");
+const cacheFolder = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "gemini")
+ : path.resolve(__dirname, `../../../storage/models/gemini`)
+);
+
+const NO_SYSTEM_PROMPT_MODELS = [
+ "gemma-3-1b-it",
+ "gemma-3-4b-it",
+ "gemma-3-12b-it",
+ "gemma-3-27b-it",
+];
+
+class GeminiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.GEMINI_API_KEY)
+ throw new Error("No Gemini API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.model =
+ modelPreference ||
+ process.env.GEMINI_LLM_MODEL_PREF ||
+ "gemini-2.0-flash-lite";
+
+ const isExperimental = this.isExperimentalModel(this.model);
+ this.openai = new OpenAIApi({
+ apiKey: process.env.GEMINI_API_KEY,
+ // Even models that are v1 in gemini API can be used with v1beta/openai/ endpoint and nobody knows why.
+ baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
+ });
+
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ this.cacheModelPath = path.resolve(cacheFolder, "models.json");
+ this.cacheAtPath = path.resolve(cacheFolder, ".cached_at");
+ this.#log(
+ `Initialized with model: ${this.model} ${isExperimental ? "[Experimental v1beta]" : "[Stable v1]"} - ctx: ${this.promptWindowLimit()}`
+ );
+ }
+
+ /**
+ * Checks if the model supports system prompts
+ * This is a static list of models that are known to not support system prompts
+ * since this information is not available in the API model response.
+ * @returns {boolean}
+ */
+ get supportsSystemPrompt() {
+ return !NO_SYSTEM_PROMPT_MODELS.includes(this.model);
+ }
+
+ #log(text, ...args) {
+ console.log(`\x1b[32m[GeminiLLM]\x1b[0m ${text}`, ...args);
+ }
+
+ // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis)
+ // from the current date. If it is, then we will refetch the API so that all the models are up
+ // to date.
+ static cacheIsStale() {
+ const MAX_STALE = 8.64e7; // 1 day in MS
+ if (!fs.existsSync(path.resolve(cacheFolder, ".cached_at"))) return true;
+ const now = Number(new Date());
+ const timestampMs = Number(
+ fs.readFileSync(path.resolve(cacheFolder, ".cached_at"))
+ );
+ return now - timestampMs > MAX_STALE;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ try {
+ const cacheModelPath = path.resolve(cacheFolder, "models.json");
+ if (!fs.existsSync(cacheModelPath))
+ return MODEL_MAP.get("gemini", modelName) ?? 30_720;
+
+ const models = safeJsonParse(fs.readFileSync(cacheModelPath));
+ const model = models.find((model) => model.id === modelName);
+ if (!model)
+ throw new Error(
+ "Model not found in cache - falling back to default model."
+ );
+ return model.contextWindow;
+ } catch (e) {
+ console.error(`GeminiLLM:promptWindowLimit`, e.message);
+ return MODEL_MAP.get("gemini", modelName) ?? 30_720;
+ }
+ }
+
+ promptWindowLimit() {
+ try {
+ if (!fs.existsSync(this.cacheModelPath))
+ return MODEL_MAP.get("gemini", this.model) ?? 30_720;
+ const models = safeJsonParse(fs.readFileSync(this.cacheModelPath));
+ const model = models.find((model) => model.id === this.model);
+ if (!model)
+ throw new Error(
+ "Model not found in cache - falling back to default model."
+ );
+ return model.contextWindow;
+ } catch (e) {
+ console.error(`GeminiLLM:promptWindowLimit`, e.message);
+ return MODEL_MAP.get("gemini", this.model) ?? 30_720;
+ }
+ }
+
+ /**
+ * Checks if a model is experimental by reading from the cache if available, otherwise it will perform
+ * a blind check against the v1BetaModels list - which is manually maintained and updated.
+ * @param {string} modelName - The name of the model to check
+ * @returns {boolean} A boolean indicating if the model is experimental
+ */
+ isExperimentalModel(modelName) {
+ if (
+ fs.existsSync(cacheFolder) &&
+ fs.existsSync(path.resolve(cacheFolder, "models.json"))
+ ) {
+ const models = safeJsonParse(
+ fs.readFileSync(path.resolve(cacheFolder, "models.json"))
+ );
+ const model = models.find((model) => model.id === modelName);
+ if (!model) return false;
+ return model.experimental;
+ }
+
+ return modelName.includes("exp") || v1BetaModels.includes(modelName);
+ }
+
+ /**
+ * Fetches Gemini models from the Google Generative AI API
+ * @param {string} apiKey - The API key to use for the request
+ * @param {number} limit - The maximum number of models to fetch
+ * @param {string} pageToken - The page token to use for pagination
+ * @returns {Promise<[{id: string, name: string, contextWindow: number, experimental: boolean}]>} A promise that resolves to an array of Gemini models
+ */
+ static async fetchModels(apiKey, limit = 1_000, pageToken = null) {
+ if (!apiKey) return [];
+ if (fs.existsSync(cacheFolder) && !this.cacheIsStale()) {
+ console.log(
+ `\x1b[32m[GeminiLLM]\x1b[0m Using cached models API response.`
+ );
+ return safeJsonParse(
+ fs.readFileSync(path.resolve(cacheFolder, "models.json"))
+ );
+ }
+
+ const stableModels = [];
+ const allModels = [];
+
+ // Fetch from v1
+ try {
+ const url = new URL(
+ "https://generativelanguage.googleapis.com/v1/models"
+ );
+ url.searchParams.set("pageSize", limit);
+ url.searchParams.set("key", apiKey);
+ if (pageToken) url.searchParams.set("pageToken", pageToken);
+ await fetch(url.toString(), {
+ method: "GET",
+ headers: { "Content-Type": "application/json" },
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.error) throw new Error(data.error.message);
+ return data.models ?? [];
+ })
+ .then((models) => {
+ return models
+ .filter(
+ (model) => !model.displayName?.toLowerCase()?.includes("tuning")
+ ) // remove tuning models
+ .filter(
+ (model) =>
+ !model.description?.toLowerCase()?.includes("deprecated")
+ ) // remove deprecated models (in comment)
+ .filter((model) =>
+ // Only generateContent is supported
+ model.supportedGenerationMethods.includes("generateContent")
+ )
+ .map((model) => {
+ stableModels.push(model.name);
+ allModels.push({
+ id: model.name.split("/").pop(),
+ name: model.displayName,
+ contextWindow: model.inputTokenLimit,
+ experimental: false,
+ });
+ });
+ })
+ .catch((e) => {
+ console.error(`Gemini:getGeminiModelsV1`, e.message);
+ return;
+ });
+ } catch (e) {
+ console.error(`Gemini:getGeminiModelsV1`, e.message);
+ }
+
+ // Fetch from v1beta
+ try {
+ const url = new URL(
+ "https://generativelanguage.googleapis.com/v1beta/models"
+ );
+ url.searchParams.set("pageSize", limit);
+ url.searchParams.set("key", apiKey);
+ if (pageToken) url.searchParams.set("pageToken", pageToken);
+ await fetch(url.toString(), {
+ method: "GET",
+ headers: { "Content-Type": "application/json" },
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.error) throw new Error(data.error.message);
+ return data.models ?? [];
+ })
+ .then((models) => {
+ return models
+ .filter((model) => !stableModels.includes(model.name)) // remove stable models that are already in the v1 list
+ .filter(
+ (model) => !model.displayName?.toLowerCase()?.includes("tuning")
+ ) // remove tuning models
+ .filter(
+ (model) =>
+ !model.description?.toLowerCase()?.includes("deprecated")
+ ) // remove deprecated models (in comment)
+ .filter((model) =>
+ // Only generateContent is supported
+ model.supportedGenerationMethods.includes("generateContent")
+ )
+ .map((model) => {
+ allModels.push({
+ id: model.name.split("/").pop(),
+ name: model.displayName,
+ contextWindow: model.inputTokenLimit,
+ experimental: true,
+ });
+ });
+ })
+ .catch((e) => {
+ console.error(`Gemini:getGeminiModelsV1beta`, e.message);
+ return;
+ });
+ } catch (e) {
+ console.error(`Gemini:getGeminiModelsV1beta`, e.message);
+ }
+
+ if (allModels.length === 0) {
+ console.error(`Gemini:getGeminiModels - No models found`);
+ return defaultGeminiModels();
+ }
+
+ console.log(
+ `\x1b[32m[GeminiLLM]\x1b[0m Writing cached models API response to disk.`
+ );
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ fs.writeFileSync(
+ path.resolve(cacheFolder, "models.json"),
+ JSON.stringify(allModels)
+ );
+ fs.writeFileSync(
+ path.resolve(cacheFolder, ".cached_at"),
+ new Date().getTime().toString()
+ );
+
+ return allModels;
+ }
+
+ /**
+ * Checks if a model is valid for chat completion (unused)
+ * @deprecated
+ * @param {string} modelName - The name of the model to check
+ * @returns {Promise} A promise that resolves to a boolean indicating if the model is valid
+ */
+ async isValidChatCompletionModel(modelName = "") {
+ const models = await this.fetchModels(process.env.GEMINI_API_KEY);
+ return models.some((model) => model.id === modelName);
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) return userPrompt;
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "high",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ let prompt = [];
+ if (this.supportsSystemPrompt) {
+ prompt.push({
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ });
+ } else {
+ this.#log(
+ `${this.model} - does not support system prompts - emulating...`
+ );
+ prompt.push(
+ {
+ role: "user",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ },
+ {
+ role: "assistant",
+ content: "Okay.",
+ }
+ );
+ }
+
+ return [
+ ...prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature: temperature,
+ })
+ .catch((e) => {
+ console.error(e);
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature: temperature,
+ }),
+ messages,
+ true
+ );
+
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+}
+
+module.exports = {
+ GeminiLLM,
+ NO_SYSTEM_PROMPT_MODELS,
+};
diff --git a/server/utils/AiProviders/gemini/syncStaticLists.mjs b/server/utils/AiProviders/gemini/syncStaticLists.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..b276995acdf6930cc4bfff350c9646dbeb8f4e5d
--- /dev/null
+++ b/server/utils/AiProviders/gemini/syncStaticLists.mjs
@@ -0,0 +1,48 @@
+/**
+ * This is a script that syncs the static lists of models from the Gemini API
+ * so that maintainers can keep the fallback lists up to date.
+ *
+ * To run, cd into this directory and run:
+ * node syncStaticLists.mjs
+ */
+
+import fs from "fs";
+import path from "path";
+import dotenv from "dotenv";
+
+dotenv.config({ path: `../../../.env.development` });
+const existingCachePath = path.resolve('../../../storage/models/gemini')
+
+// This will fetch all of the models from the Gemini API as well as post-process them
+// to remove any models that are deprecated or experimental.
+import { GeminiLLM } from "./index.js";
+
+if (fs.existsSync(existingCachePath)) {
+ console.log("Removing existing cache so we can fetch fresh models from Gemini endpoints...");
+ fs.rmSync(existingCachePath, { recursive: true, force: true });
+}
+
+const models = await GeminiLLM.fetchModels(process.env.GEMINI_API_KEY);
+
+function updateDefaultModelsFile(models) {
+ const stableModelKeys = models.filter((model) => !model.experimental).map((model) => model.id);
+ const v1BetaModelKeys = models.filter((model) => model.experimental).map((model) => model.id);
+
+ let defaultModelFileContents = fs.readFileSync(path.join("./defaultModels.js"), "utf8");
+
+ // Update the stable models between %STABLE_MODELS% and %EOC_STABLE_MODELS% comments
+ defaultModelFileContents = defaultModelFileContents.replace(
+ /%STABLE_MODELS%[\s\S]*?%EOC_STABLE_MODELS%/,
+ `%STABLE_MODELS% - updated ${new Date().toISOString()}\n"${stableModelKeys.join('",\n"')}",\n// %EOC_STABLE_MODELS%`
+ );
+
+ // Update the v1beta models between %V1BETA_MODELS% and %EOC_V1BETA_MODELS% comments
+ defaultModelFileContents = defaultModelFileContents.replace(
+ /%V1BETA_MODELS%[\s\S]*?%EOC_V1BETA_MODELS%/,
+ `%V1BETA_MODELS% - updated ${new Date().toISOString()}\n"${v1BetaModelKeys.join('",\n"')}",\n// %EOC_V1BETA_MODELS%`
+ );
+
+ fs.writeFileSync(path.join("./defaultModels.js"), defaultModelFileContents);
+ console.log("Updated defaultModels.js. Dont forget to `yarn lint` and commit!");
+}
+updateDefaultModelsFile(models);
diff --git a/server/utils/AiProviders/genericOpenAi/index.js b/server/utils/AiProviders/genericOpenAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..3b4c179d1edefff2b3ef4d2f2ee2a0c4cfe4c7db
--- /dev/null
+++ b/server/utils/AiProviders/genericOpenAi/index.js
@@ -0,0 +1,369 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ formatChatHistory,
+ writeResponseChunk,
+ clientAbortedHandler,
+} = require("../../helpers/chat/responses");
+const { toValidNumber } = require("../../http");
+const { getAnythingLLMUserAgent } = require("../../../endpoints/utils");
+
+class GenericOpenAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ const { OpenAI: OpenAIApi } = require("openai");
+ if (!process.env.GENERIC_OPEN_AI_BASE_PATH)
+ throw new Error(
+ "GenericOpenAI must have a valid base path to use for the api."
+ );
+
+ this.basePath = process.env.GENERIC_OPEN_AI_BASE_PATH;
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.GENERIC_OPEN_AI_API_KEY ?? null,
+ defaultHeaders: {
+ "User-Agent": getAnythingLLMUserAgent(),
+ },
+ });
+ this.model =
+ modelPreference ?? process.env.GENERIC_OPEN_AI_MODEL_PREF ?? null;
+ this.maxTokens = process.env.GENERIC_OPEN_AI_MAX_TOKENS
+ ? toValidNumber(process.env.GENERIC_OPEN_AI_MAX_TOKENS, 1024)
+ : 1024;
+ if (!this.model)
+ throw new Error("GenericOpenAI must have a valid model set.");
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(`Inference API: ${this.basePath} Model: ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ if (process.env.GENERIC_OPENAI_STREAMING_DISABLED === "true") return false;
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Short circuit since we have no idea if the model is valid or not
+ // in pre-flight for generic endpoints
+ isValidChatCompletionModel(_modelName = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ *
+ * ## Developer Note
+ * This function assumes the generic OpenAI provider is _actually_ OpenAI compatible.
+ * For example, Ollama is "OpenAI compatible" but does not support images as a content array.
+ * The contentString also is the base64 string WITH `data:image/xxx;base64,` prefix, which may not be the case for all providers.
+ * If your provider does not work exactly this way, then attachments will not function or potentially break vision requests.
+ * If you encounter this issue, you are welcome to open an issue asking for your specific provider to be supported.
+ *
+ * This function will **not** be updated for providers that **do not** support images as a content array like OpenAI does.
+ * Do not open issues to update this function due to your specific provider not being compatible. Open an issue to request support for your specific provider.
+ * @param {Object} props
+ * @param {string} props.userPrompt - the user prompt to be sent to the model
+ * @param {import("../../helpers").Attachment[]} props.attachments - the array of attachments to be sent to the model
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "high",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ /**
+ * Parses and prepends reasoning from the response and returns the full text response.
+ * @param {Object} response
+ * @returns {string}
+ */
+ #parseReasoningFromResponse({ message }) {
+ let textResponse = message?.content;
+ if (
+ !!message?.reasoning_content &&
+ message.reasoning_content.trim().length > 0
+ )
+ textResponse = `${message.reasoning_content} ${textResponse}`;
+ return textResponse;
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ max_tokens: this.maxTokens,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: this.#parseReasoningFromResponse(result.output.choices[0]),
+ metrics: {
+ prompt_tokens: result.output?.usage?.prompt_tokens || 0,
+ completion_tokens: result.output?.usage?.completion_tokens || 0,
+ total_tokens: result.output?.usage?.total_tokens || 0,
+ outputTps:
+ (result.output?.usage?.completion_tokens || 0) / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ max_tokens: this.maxTokens,
+ }),
+ messages
+ // runPromptTokenCalculation: true - There is not way to know if the generic provider connected is returning
+ // the correct usage metrics if any at all since any provider could be connected.
+ );
+ return measuredStreamRequest;
+ }
+
+ // TODO: This is a copy of the generic handleStream function in responses.js
+ // to specifically handle the DeepSeek reasoning model `reasoning_content` field.
+ // When or if ever possible, we should refactor this to be in the generic function.
+ handleStream(response, stream, responseProps) {
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+ let hasUsageMetrics = false;
+ let usage = {
+ completion_tokens: 0,
+ };
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let reasoningText = "";
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement(usage);
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ try {
+ for await (const chunk of stream) {
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+ const reasoningToken = message?.delta?.reasoning_content;
+
+ if (
+ chunk.hasOwnProperty("usage") && // exists
+ !!chunk.usage && // is not null
+ Object.values(chunk.usage).length > 0 // has values
+ ) {
+ if (chunk.usage.hasOwnProperty("prompt_tokens")) {
+ usage.prompt_tokens = Number(chunk.usage.prompt_tokens);
+ }
+
+ if (chunk.usage.hasOwnProperty("completion_tokens")) {
+ hasUsageMetrics = true; // to stop estimating counter
+ usage.completion_tokens = Number(chunk.usage.completion_tokens);
+ }
+ }
+
+ // Reasoning models will always return the reasoning text before the token text.
+ if (reasoningToken) {
+ // If the reasoning text is empty (''), we need to initialize it
+ // and send the first chunk of reasoning text.
+ if (reasoningText.length === 0) {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: `${reasoningToken}`,
+ close: false,
+ error: false,
+ });
+ reasoningText += `${reasoningToken}`;
+ continue;
+ } else {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: reasoningToken,
+ close: false,
+ error: false,
+ });
+ reasoningText += reasoningToken;
+ }
+ }
+
+ // If the reasoning text is not empty, but the reasoning token is empty
+ // and the token text is not empty we need to close the reasoning text and begin sending the token text.
+ if (!!reasoningText && !reasoningToken && token) {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: ` `,
+ close: false,
+ error: false,
+ });
+ fullText += `${reasoningText} `;
+ reasoningText = "";
+ }
+
+ if (token) {
+ fullText += token;
+ // If we never saw a usage metric, we can estimate them by number of completion chunks
+ if (!hasUsageMetrics) usage.completion_tokens++;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+
+ if (
+ message?.hasOwnProperty("finish_reason") && // Got valid message and it is an object with finish_reason
+ message.finish_reason !== "" &&
+ message.finish_reason !== null
+ ) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ break; // Break streaming when a valid finish_reason is first encountered
+ }
+ }
+ } catch (e) {
+ console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`);
+ writeResponseChunk(response, {
+ uuid,
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ GenericOpenAiLLM,
+};
diff --git a/server/utils/AiProviders/groq/index.js b/server/utils/AiProviders/groq/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..8eddefab0c5138b15c7abc4bc408bbc51b634c1f
--- /dev/null
+++ b/server/utils/AiProviders/groq/index.js
@@ -0,0 +1,251 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+const { MODEL_MAP } = require("../modelMap");
+
+class GroqLLM {
+ constructor(embedder = null, modelPreference = null) {
+ const { OpenAI: OpenAIApi } = require("openai");
+ if (!process.env.GROQ_API_KEY) throw new Error("No Groq API key was set.");
+
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.groq.com/openai/v1",
+ apiKey: process.env.GROQ_API_KEY,
+ });
+ this.model =
+ modelPreference || process.env.GROQ_MODEL_PREF || "llama-3.1-8b-instant";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ #log(text, ...args) {
+ console.log(`\x1b[32m[GroqAi]\x1b[0m ${text}`, ...args);
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ return MODEL_MAP.get("groq", modelName) ?? 8192;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.get("groq", this.model) ?? 8192;
+ }
+
+ async isValidChatCompletionModel(modelName = "") {
+ return !!modelName; // name just needs to exist
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) return userPrompt;
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Last Updated: October 21, 2024
+ * According to https://console.groq.com/docs/vision
+ * the vision models supported all make a mess of prompting depending on the model.
+ * Currently the llama3.2 models are only in preview and subject to change and the llava model is deprecated - so we will not support attachments for that at all.
+ *
+ * Since we can only explicitly support the current models, this is a temporary solution.
+ * If the attachments are empty or the model is not a vision model, we will return the default prompt structure which will work for all models.
+ * If the attachments are present and the model is a vision model - we only return the user prompt with attachments - see comment at end of function for more.
+ *
+ * Historical attachments are also omitted from prompt chat history for the reasons above. (TDC: Dec 30, 2024)
+ */
+ #conditionalPromptStruct({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ const VISION_MODELS = [
+ "llama-3.2-90b-vision-preview",
+ "llama-3.2-11b-vision-preview",
+ ];
+ const DEFAULT_PROMPT_STRUCT = [
+ {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ },
+ ...chatHistory,
+ { role: "user", content: userPrompt },
+ ];
+
+ // If there are no attachments or model is not a vision model, return the default prompt structure
+ // as there is nothing to attach or do and no model limitations to consider
+ if (!attachments.length) return DEFAULT_PROMPT_STRUCT;
+ if (!VISION_MODELS.includes(this.model)) {
+ this.#log(
+ `${this.model} is not an explicitly supported vision model! Will omit attachments.`
+ );
+ return DEFAULT_PROMPT_STRUCT;
+ }
+
+ return [
+ // Why is the system prompt and history commented out?
+ // The current vision models for Groq perform VERY poorly with ANY history or text prior to the image.
+ // In order to not get LLM refusals for every single message, we will not include the "system prompt" or even the chat history.
+ // This is a temporary solution until Groq fixes their vision models to be more coherent and also handle context prior to the image.
+ // Note for the future:
+ // Groq vision models also do not support system prompts - which is why you see the user/assistant emulation used instead of "system".
+ // This means any vision call is assessed independently of the chat context prior to the image.
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // {
+ // role: "user",
+ // content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ // },
+ // {
+ // role: "assistant",
+ // content: "OK",
+ // },
+ // ...chatHistory,
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ // NOTICE: SEE GroqLLM.#conditionalPromptStruct for more information on how attachments are handled with Groq.
+ return this.#conditionalPromptStruct({
+ systemPrompt,
+ contextTexts,
+ chatHistory,
+ userPrompt,
+ attachments,
+ });
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `GroqAI:chatCompletion: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps:
+ result.output.usage.completion_tokens /
+ result.output.usage.completion_time,
+ duration: result.output.usage.total_time,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `GroqAI:streamChatCompletion: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages,
+ false
+ );
+
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ GroqLLM,
+};
diff --git a/server/utils/AiProviders/huggingface/index.js b/server/utils/AiProviders/huggingface/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f4b6100e0d4c3b0f9e20275677e942ec9dc0a9e0
--- /dev/null
+++ b/server/utils/AiProviders/huggingface/index.js
@@ -0,0 +1,158 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+
+class HuggingFaceLLM {
+ constructor(embedder = null, _modelPreference = null) {
+ if (!process.env.HUGGING_FACE_LLM_ENDPOINT)
+ throw new Error("No HuggingFace Inference Endpoint was set.");
+ if (!process.env.HUGGING_FACE_LLM_API_KEY)
+ throw new Error("No HuggingFace Access Token was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+
+ this.openai = new OpenAIApi({
+ baseURL: `${process.env.HUGGING_FACE_LLM_ENDPOINT}/v1`,
+ apiKey: process.env.HUGGING_FACE_LLM_API_KEY,
+ });
+ // When using HF inference server - the model param is not required so
+ // we can stub it here. HF Endpoints can only run one model at a time.
+ // We set to 'tgi' so that endpoint for HF can accept message format
+ this.model = "tgi";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.2;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.HUGGING_FACE_LLM_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No HuggingFace token context limit was set.");
+ return Number(limit);
+ }
+
+ promptWindowLimit() {
+ const limit = process.env.HUGGING_FACE_LLM_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No HuggingFace token context limit was set.");
+ return Number(limit);
+ }
+
+ async isValidChatCompletionModel(_ = "") {
+ return true;
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ }) {
+ // System prompt it not enabled for HF model chats
+ const prompt = {
+ role: "user",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ const assistantResponse = {
+ role: "assistant",
+ content: "Okay, I will follow those instructions",
+ };
+ return [
+ prompt,
+ assistantResponse,
+ ...chatHistory,
+ { role: "user", content: userPrompt },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps:
+ (result.output.usage?.completion_tokens || 0) / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ HuggingFaceLLM,
+};
diff --git a/server/utils/AiProviders/koboldCPP/index.js b/server/utils/AiProviders/koboldCPP/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..64fa8c456cad73f9344001d0759d4ec9f4fdb6f6
--- /dev/null
+++ b/server/utils/AiProviders/koboldCPP/index.js
@@ -0,0 +1,257 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ clientAbortedHandler,
+ writeResponseChunk,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const { v4: uuidv4 } = require("uuid");
+
+class KoboldCPPLLM {
+ constructor(embedder = null, modelPreference = null) {
+ const { OpenAI: OpenAIApi } = require("openai");
+ if (!process.env.KOBOLD_CPP_BASE_PATH)
+ throw new Error(
+ "KoboldCPP must have a valid base path to use for the api."
+ );
+
+ this.basePath = process.env.KOBOLD_CPP_BASE_PATH;
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: null,
+ });
+ this.model = modelPreference ?? process.env.KOBOLD_CPP_MODEL_PREF ?? null;
+ if (!this.model) throw new Error("KoboldCPP must have a valid model set.");
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.maxTokens = Number(process.env.KOBOLD_CPP_MAX_TOKENS) || 2048;
+ this.log(`Inference API: ${this.basePath} Model: ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.KOBOLD_CPP_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.KOBOLD_CPP_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Short circuit since we have no idea if the model is valid or not
+ // in pre-flight for generic endpoints
+ isValidChatCompletionModel(_modelName = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ max_tokens: this.maxTokens,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ const promptTokens = LLMPerformanceMonitor.countTokens(messages);
+ const completionTokens = LLMPerformanceMonitor.countTokens([
+ { content: result.output.choices[0].message.content },
+ ]);
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: promptTokens,
+ completion_tokens: completionTokens,
+ total_tokens: promptTokens + completionTokens,
+ outputTps: completionTokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ max_tokens: this.maxTokens,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let usage = {
+ prompt_tokens: LLMPerformanceMonitor.countTokens(stream.messages || []),
+ completion_tokens: 0,
+ };
+
+ const handleAbort = () => {
+ usage.completion_tokens = LLMPerformanceMonitor.countTokens([
+ { content: fullText },
+ ]);
+ stream?.endMeasurement(usage);
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ for await (const chunk of stream) {
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+
+ if (token) {
+ fullText += token;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+
+ // KoboldCPP finishes with "length" or "stop"
+ if (
+ message.finish_reason !== "null" &&
+ (message.finish_reason === "length" ||
+ message.finish_reason === "stop")
+ ) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ usage.completion_tokens = LLMPerformanceMonitor.countTokens([
+ { content: fullText },
+ ]);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ }
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ KoboldCPPLLM,
+};
diff --git a/server/utils/AiProviders/liteLLM/index.js b/server/utils/AiProviders/liteLLM/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..2017d7774f8b0d10a48c3565da843443a02cc2ff
--- /dev/null
+++ b/server/utils/AiProviders/liteLLM/index.js
@@ -0,0 +1,198 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+
+class LiteLLM {
+ constructor(embedder = null, modelPreference = null) {
+ const { OpenAI: OpenAIApi } = require("openai");
+ if (!process.env.LITE_LLM_BASE_PATH)
+ throw new Error(
+ "LiteLLM must have a valid base path to use for the api."
+ );
+
+ this.basePath = process.env.LITE_LLM_BASE_PATH;
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.LITE_LLM_API_KEY ?? null,
+ });
+ this.model = modelPreference ?? process.env.LITE_LLM_MODEL_PREF ?? null;
+ this.maxTokens = process.env.LITE_LLM_MODEL_TOKEN_LIMIT ?? 1024;
+ if (!this.model) throw new Error("LiteLLM must have a valid model set.");
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(`Inference API: ${this.basePath} Model: ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.LITE_LLM_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.LITE_LLM_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Short circuit since we have no idea if the model is valid or not
+ // in pre-flight for generic endpoints
+ isValidChatCompletionModel(_modelName = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ max_tokens: parseInt(this.maxTokens), // LiteLLM requires int
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps:
+ (result.output.usage?.completion_tokens || 0) / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ max_tokens: parseInt(this.maxTokens), // LiteLLM requires int
+ }),
+ messages
+ // runPromptTokenCalculation: true - We manually count the tokens because they may or may not be provided in the stream
+ // responses depending on LLM connected. If they are provided, then we counted for nothing, but better than nothing.
+ );
+
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ LiteLLM,
+};
diff --git a/server/utils/AiProviders/lmStudio/index.js b/server/utils/AiProviders/lmStudio/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..bde9ed486b3a56fb2db404d84bb4ebfd9d52f314
--- /dev/null
+++ b/server/utils/AiProviders/lmStudio/index.js
@@ -0,0 +1,217 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+// hybrid of openAi LLM chat completion for LMStudio
+class LMStudioLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.LMSTUDIO_BASE_PATH)
+ throw new Error("No LMStudio API Base Path was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.lmstudio = new OpenAIApi({
+ baseURL: parseLMStudioBasePath(process.env.LMSTUDIO_BASE_PATH), // here is the URL to your LMStudio instance
+ apiKey: null,
+ });
+
+ // Prior to LMStudio 0.2.17 the `model` param was not required and you could pass anything
+ // into that field and it would work. On 0.2.17 LMStudio introduced multi-model chat
+ // which now has a bug that reports the server model id as "Loaded from Chat UI"
+ // and any other value will crash inferencing. So until this is patched we will
+ // try to fetch the `/models` and have the user set it, or just fallback to "Loaded from Chat UI"
+ // which will not impact users with {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.LMSTUDIO_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No LMStudio token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.LMSTUDIO_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No LMStudio token context limit was set.");
+ return Number(limit);
+ }
+
+ async isValidChatCompletionModel(_ = "") {
+ // LMStudio may be anything. The user must do it correctly.
+ // See comment about this.model declaration in constructor
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ `LMStudio chat: ${this.model} is not valid or defined model for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.lmstudio.chat.completions.create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps: result.output.usage?.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ `LMStudio chat: ${this.model} is not valid or defined model for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.lmstudio.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+/**
+ * Parse the base path for the LMStudio API. Since the base path must end in /v1 and cannot have a trailing slash,
+ * and the user can possibly set it to anything and likely incorrectly due to pasting behaviors, we need to ensure it is in the correct format.
+ * @param {string} basePath
+ * @returns {string}
+ */
+function parseLMStudioBasePath(providedBasePath = "") {
+ try {
+ const baseURL = new URL(providedBasePath);
+ const basePath = `${baseURL.origin}/v1`;
+ return basePath;
+ } catch (e) {
+ return providedBasePath;
+ }
+}
+
+module.exports = {
+ LMStudioLLM,
+ parseLMStudioBasePath,
+};
diff --git a/server/utils/AiProviders/localAi/index.js b/server/utils/AiProviders/localAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f62fe70dd9d0566b3173b402a9b11aa5283a162e
--- /dev/null
+++ b/server/utils/AiProviders/localAi/index.js
@@ -0,0 +1,191 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+
+class LocalAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.LOCAL_AI_BASE_PATH)
+ throw new Error("No LocalAI Base Path was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ baseURL: process.env.LOCAL_AI_BASE_PATH,
+ apiKey: process.env.LOCAL_AI_API_KEY ?? null,
+ });
+ this.model = modelPreference || process.env.LOCAL_AI_MODEL_PREF;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.LOCAL_AI_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No LocalAi token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.LOCAL_AI_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No LocalAi token context limit was set.");
+ return Number(limit);
+ }
+
+ async isValidChatCompletionModel(_ = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `LocalAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions.create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ const promptTokens = LLMPerformanceMonitor.countTokens(messages);
+ const completionTokens = LLMPerformanceMonitor.countTokens(
+ result.output.choices[0].message.content
+ );
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: promptTokens,
+ completion_tokens: completionTokens,
+ total_tokens: promptTokens + completionTokens,
+ outputTps: completionTokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `LocalAi chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ LocalAiLLM,
+};
diff --git a/server/utils/AiProviders/mistral/index.js b/server/utils/AiProviders/mistral/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..6c637857b357d80fca82c710d745f5aa5ee213b5
--- /dev/null
+++ b/server/utils/AiProviders/mistral/index.js
@@ -0,0 +1,185 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+
+class MistralLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.MISTRAL_API_KEY)
+ throw new Error("No Mistral API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.mistral.ai/v1",
+ apiKey: process.env.MISTRAL_API_KEY ?? null,
+ });
+ this.model =
+ modelPreference || process.env.MISTRAL_MODEL_PREF || "mistral-tiny";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.0;
+ this.log("Initialized with model:", this.model);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit() {
+ return 32000;
+ }
+
+ promptWindowLimit() {
+ return 32000;
+ }
+
+ async isValidChatCompletionModel(modelName = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) return userPrompt;
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: attachment.contentString,
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Mistral chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Mistral chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages,
+ false
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ MistralLLM,
+};
diff --git a/server/utils/AiProviders/modelMap/index.js b/server/utils/AiProviders/modelMap/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..8ff713eeb62293156f094fc5b85aeb4545ee4d51
--- /dev/null
+++ b/server/utils/AiProviders/modelMap/index.js
@@ -0,0 +1,207 @@
+const path = require("path");
+const fs = require("fs");
+const LEGACY_MODEL_MAP = require("./legacy");
+
+class ContextWindowFinder {
+ static instance = null;
+ static modelMap = LEGACY_MODEL_MAP;
+
+ /**
+ * Mapping for AnythingLLM provider <> LiteLLM provider
+ * @type {Record}
+ */
+ static trackedProviders = {
+ anthropic: "anthropic",
+ openai: "openai",
+ cohere: "cohere_chat",
+ gemini: "vertex_ai-language-models",
+ groq: "groq",
+ xai: "xai",
+ deepseek: "deepseek",
+ moonshot: "moonshot",
+ };
+ static expiryMs = 1000 * 60 * 60 * 24 * 3; // 3 days
+ static remoteUrl =
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
+
+ cacheLocation = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "context-windows")
+ : path.resolve(__dirname, `../../../storage/models/context-windows`)
+ );
+ cacheFilePath = path.resolve(this.cacheLocation, "context-windows.json");
+ cacheFileExpiryPath = path.resolve(this.cacheLocation, ".cached_at");
+ seenStaleCacheWarning = false;
+
+ constructor() {
+ if (ContextWindowFinder.instance) return ContextWindowFinder.instance;
+ ContextWindowFinder.instance = this;
+ if (!fs.existsSync(this.cacheLocation))
+ fs.mkdirSync(this.cacheLocation, { recursive: true });
+
+ // If the cache is stale or not found at all, pull the model map from remote
+ if (this.isCacheStale || !fs.existsSync(this.cacheFilePath))
+ this.#pullRemoteModelMap();
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[33m[ContextWindowFinder]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Checks if the cache is stale by checking if the cache file exists and if the cache file is older than the expiry time.
+ * @returns {boolean}
+ */
+ get isCacheStale() {
+ if (!fs.existsSync(this.cacheFileExpiryPath)) return true;
+ const cachedAt = fs.readFileSync(this.cacheFileExpiryPath, "utf8");
+ return Date.now() - cachedAt > ContextWindowFinder.expiryMs;
+ }
+
+ /**
+ * Gets the cached model map.
+ *
+ * Always returns the available model map - even if it is expired since re-pulling
+ * the model map only occurs on container start/system start.
+ * @returns {Record> | null} - The cached model map
+ */
+ get cachedModelMap() {
+ if (!fs.existsSync(this.cacheFilePath)) {
+ this.log(`\x1b[33m
+--------------------------------
+[WARNING] Model map cache is not found!
+Invalid context windows will be returned leading to inaccurate model responses
+or smaller context windows than expected.
+You can fix this by restarting AnythingLLM so the model map is re-pulled.
+--------------------------------\x1b[0m`);
+ return null;
+ }
+
+ if (this.isCacheStale && !this.seenStaleCacheWarning) {
+ this.log(
+ "Model map cache is stale - some model context windows may be incorrect. This is OK and the model map will be re-pulled on next boot."
+ );
+ this.seenStaleCacheWarning = true;
+ }
+
+ return JSON.parse(
+ fs.readFileSync(this.cacheFilePath, { encoding: "utf8" })
+ );
+ }
+
+ /**
+ * Pulls the remote model map from the remote URL, formats it and caches it.
+ * @returns {Record>} - The formatted model map
+ */
+ async #pullRemoteModelMap() {
+ try {
+ this.log("Pulling remote model map...");
+ const remoteContexWindowMap = await fetch(ContextWindowFinder.remoteUrl)
+ .then((res) => {
+ if (res.status !== 200)
+ throw new Error(
+ "Failed to fetch remote model map - non 200 status code"
+ );
+ return res.json();
+ })
+ .then((data) => {
+ fs.writeFileSync(this.cacheFilePath, JSON.stringify(data, null, 2));
+ fs.writeFileSync(this.cacheFileExpiryPath, Date.now().toString());
+ this.log("Remote model map synced and cached");
+ return data;
+ })
+ .catch((error) => {
+ this.log("Error syncing remote model map", error);
+ return null;
+ });
+ if (!remoteContexWindowMap) return null;
+
+ const modelMap = this.#formatModelMap(remoteContexWindowMap);
+ this.#validateModelMap(modelMap);
+ fs.writeFileSync(this.cacheFilePath, JSON.stringify(modelMap, null, 2));
+ fs.writeFileSync(this.cacheFileExpiryPath, Date.now().toString());
+ return modelMap;
+ } catch (error) {
+ this.log("Error syncing remote model map", error);
+ return null;
+ }
+ }
+
+ #validateModelMap(modelMap = {}) {
+ for (const [provider, models] of Object.entries(modelMap)) {
+ // If the models is null/falsey or has no keys, throw an error
+ if (typeof models !== "object")
+ throw new Error(
+ `Invalid model map for ${provider} - models is not an object`
+ );
+ if (!models || Object.keys(models).length === 0)
+ throw new Error(`Invalid model map for ${provider} - no models found!`);
+
+ // Validate that the context window is a number
+ for (const [model, contextWindow] of Object.entries(models)) {
+ if (isNaN(contextWindow) || contextWindow <= 0)
+ throw new Error(
+ `Invalid model map for ${provider} - context window is not a positive number for model ${model}`
+ );
+ }
+ }
+ }
+
+ /**
+ * Formats the remote model map to a format that is compatible with how we store the model map
+ * for all providers who use it.
+ * @param {Record} modelMap - The remote model map
+ * @returns {Record>} - The formatted model map
+ */
+ #formatModelMap(modelMap = {}) {
+ const formattedModelMap = {};
+
+ for (const [provider, liteLLMProviderTag] of Object.entries(
+ ContextWindowFinder.trackedProviders
+ )) {
+ formattedModelMap[provider] = {};
+ const matches = Object.entries(modelMap).filter(
+ ([_key, config]) => config.litellm_provider === liteLLMProviderTag
+ );
+ for (const [key, config] of matches) {
+ const contextWindow = Number(config.max_input_tokens);
+ if (isNaN(contextWindow)) continue;
+
+ // Some models have a provider/model-tag format, so we need to get the last part since we dont do paths
+ // for names with the exception of some router-providers like OpenRouter or Together.
+ const modelName = key.split("/").pop();
+ formattedModelMap[provider][modelName] = contextWindow;
+ }
+ }
+ return formattedModelMap;
+ }
+
+ /**
+ * Gets the context window for a given provider and model.
+ *
+ * If the provider is not found, null is returned.
+ * If the model is not found, the provider's entire model map is returned.
+ *
+ * if both provider and model are provided, the context window for the given model is returned.
+ * @param {string|null} provider - The provider to get the context window for
+ * @param {string|null} model - The model to get the context window for
+ * @returns {number|null} - The context window for the given provider and model
+ */
+ get(provider = null, model = null) {
+ if (!provider || !this.cachedModelMap || !this.cachedModelMap[provider])
+ return null;
+ if (!model) return this.cachedModelMap[provider];
+
+ const modelContextWindow = this.cachedModelMap[provider][model];
+ if (!modelContextWindow) {
+ this.log("Invalid access to model context window - not found in cache", {
+ provider,
+ model,
+ });
+ return null;
+ }
+ return Number(modelContextWindow);
+ }
+}
+
+module.exports = { MODEL_MAP: new ContextWindowFinder() };
diff --git a/server/utils/AiProviders/modelMap/legacy.js b/server/utils/AiProviders/modelMap/legacy.js
new file mode 100644
index 0000000000000000000000000000000000000000..2faf99dc237aa7557698aeba5baf063853eb83ee
--- /dev/null
+++ b/server/utils/AiProviders/modelMap/legacy.js
@@ -0,0 +1,112 @@
+const LEGACY_MODEL_MAP = {
+ anthropic: {
+ "claude-instant-1.2": 100000,
+ "claude-2.0": 100000,
+ "claude-2.1": 200000,
+ "claude-3-haiku-20240307": 200000,
+ "claude-3-sonnet-20240229": 200000,
+ "claude-3-opus-20240229": 200000,
+ "claude-3-opus-latest": 200000,
+ "claude-3-5-haiku-latest": 200000,
+ "claude-3-5-haiku-20241022": 200000,
+ "claude-3-5-sonnet-latest": 200000,
+ "claude-3-5-sonnet-20241022": 200000,
+ "claude-3-5-sonnet-20240620": 200000,
+ "claude-3-7-sonnet-20250219": 200000,
+ "claude-3-7-sonnet-latest": 200000,
+ },
+ cohere: {
+ "command-r": 128000,
+ "command-r-plus": 128000,
+ command: 4096,
+ "command-light": 4096,
+ "command-nightly": 8192,
+ "command-light-nightly": 8192,
+ },
+ gemini: {
+ "gemini-1.5-pro-001": 2000000,
+ "gemini-1.5-pro-002": 2000000,
+ "gemini-1.5-pro": 2000000,
+ "gemini-1.5-flash-001": 1000000,
+ "gemini-1.5-flash": 1000000,
+ "gemini-1.5-flash-002": 1000000,
+ "gemini-1.5-flash-8b": 1000000,
+ "gemini-1.5-flash-8b-001": 1000000,
+ "gemini-2.0-flash": 1048576,
+ "gemini-2.0-flash-001": 1048576,
+ "gemini-2.0-flash-lite-001": 1048576,
+ "gemini-2.0-flash-lite": 1048576,
+ "gemini-1.5-pro-latest": 2000000,
+ "gemini-1.5-flash-latest": 1000000,
+ "gemini-1.5-flash-8b-latest": 1000000,
+ "gemini-1.5-flash-8b-exp-0827": 1000000,
+ "gemini-1.5-flash-8b-exp-0924": 1000000,
+ "gemini-2.5-pro-exp-03-25": 1048576,
+ "gemini-2.5-pro-preview-03-25": 1048576,
+ "gemini-2.0-flash-exp": 1048576,
+ "gemini-2.0-flash-exp-image-generation": 1048576,
+ "gemini-2.0-flash-lite-preview-02-05": 1048576,
+ "gemini-2.0-flash-lite-preview": 1048576,
+ "gemini-2.0-pro-exp": 1048576,
+ "gemini-2.0-pro-exp-02-05": 1048576,
+ "gemini-exp-1206": 1048576,
+ "gemini-2.0-flash-thinking-exp-01-21": 1048576,
+ "gemini-2.0-flash-thinking-exp": 1048576,
+ "gemini-2.0-flash-thinking-exp-1219": 1048576,
+ "learnlm-1.5-pro-experimental": 32767,
+ "gemma-3-1b-it": 32768,
+ "gemma-3-4b-it": 32768,
+ "gemma-3-12b-it": 32768,
+ "gemma-3-27b-it": 131072,
+ },
+ groq: {
+ "gemma2-9b-it": 8192,
+ "gemma-7b-it": 8192,
+ "llama3-70b-8192": 8192,
+ "llama3-8b-8192": 8192,
+ "llama-3.1-70b-versatile": 8000,
+ "llama-3.1-8b-instant": 8000,
+ "mixtral-8x7b-32768": 32768,
+ },
+ openai: {
+ "gpt-3.5-turbo": 16385,
+ "gpt-3.5-turbo-1106": 16385,
+ "gpt-4o": 128000,
+ "gpt-4o-2024-08-06": 128000,
+ "gpt-4o-2024-05-13": 128000,
+ "gpt-4o-mini": 128000,
+ "gpt-4o-mini-2024-07-18": 128000,
+ "gpt-4-turbo": 128000,
+ "gpt-4-1106-preview": 128000,
+ "gpt-4-turbo-preview": 128000,
+ "gpt-4": 8192,
+ "gpt-4-32k": 32000,
+ "gpt-4.1": 1047576,
+ "gpt-4.1-2025-04-14": 1047576,
+ "gpt-4.1-mini": 1047576,
+ "gpt-4.1-mini-2025-04-14": 1047576,
+ "gpt-4.1-nano": 1047576,
+ "gpt-4.1-nano-2025-04-14": 1047576,
+ "gpt-4.5-preview": 128000,
+ "gpt-4.5-preview-2025-02-27": 128000,
+ "o1-preview": 128000,
+ "o1-preview-2024-09-12": 128000,
+ "o1-mini": 128000,
+ "o1-mini-2024-09-12": 128000,
+ o1: 200000,
+ "o1-2024-12-17": 200000,
+ "o1-pro": 200000,
+ "o1-pro-2025-03-19": 200000,
+ "o3-mini": 200000,
+ "o3-mini-2025-01-31": 200000,
+ },
+ deepseek: {
+ "deepseek-chat": 128000,
+ "deepseek-coder": 128000,
+ "deepseek-reasoner": 128000,
+ },
+ xai: {
+ "grok-beta": 131072,
+ },
+};
+module.exports = LEGACY_MODEL_MAP;
diff --git a/server/utils/AiProviders/moonshotAi/index.js b/server/utils/AiProviders/moonshotAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..c4bc7b65bbd026006270d8880deee57cffcc0b1c
--- /dev/null
+++ b/server/utils/AiProviders/moonshotAi/index.js
@@ -0,0 +1,169 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const { MODEL_MAP } = require("../modelMap");
+
+class MoonshotAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.MOONSHOT_AI_API_KEY)
+ throw new Error("No Moonshot AI API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.moonshot.ai/v1",
+ apiKey: process.env.MOONSHOT_AI_API_KEY,
+ });
+ this.model =
+ modelPreference ||
+ process.env.MOONSHOT_AI_MODEL_PREF ||
+ "moonshot-v1-32k";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(
+ `Initialized ${this.model} with context window ${this.promptWindowLimit()}`
+ );
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ streamingEnabled() {
+ return true;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.get("moonshot", this.model) ?? 8_192;
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !Object.prototype.hasOwnProperty.call(result.output, "choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+}
+
+module.exports = { MoonshotAiLLM };
diff --git a/server/utils/AiProviders/novita/index.js b/server/utils/AiProviders/novita/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..08be9cf83cb8a432d68f1e62e648e7fd63863800
--- /dev/null
+++ b/server/utils/AiProviders/novita/index.js
@@ -0,0 +1,425 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const { v4: uuidv4 } = require("uuid");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const fs = require("fs");
+const path = require("path");
+const { safeJsonParse } = require("../../http");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const cacheFolder = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "novita")
+ : path.resolve(__dirname, `../../../storage/models/novita`)
+);
+
+class NovitaLLM {
+ defaultTimeout = 3_000;
+
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.NOVITA_LLM_API_KEY)
+ throw new Error("No Novita API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.basePath = "https://api.novita.ai/v3/openai";
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.NOVITA_LLM_API_KEY ?? null,
+ defaultHeaders: {
+ "HTTP-Referer": "https://anythingllm.com",
+ "X-Novita-Source": "anythingllm",
+ },
+ });
+ this.model =
+ modelPreference ||
+ process.env.NOVITA_LLM_MODEL_PREF ||
+ "deepseek/deepseek-r1";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.timeout = this.#parseTimeout();
+
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ this.cacheModelPath = path.resolve(cacheFolder, "models.json");
+ this.cacheAtPath = path.resolve(cacheFolder, ".cached_at");
+
+ this.log(`Loaded with model: ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Novita has various models that never return `finish_reasons` and thus leave the stream open
+ * which causes issues in subsequent messages. This timeout value forces us to close the stream after
+ * x milliseconds. This is a configurable value via the NOVITA_LLM_TIMEOUT_MS value
+ * @returns {number} The timeout value in milliseconds (default: 3_000)
+ */
+ #parseTimeout() {
+ this.log(
+ `Novita timeout is set to ${process.env.NOVITA_LLM_TIMEOUT_MS ?? this.defaultTimeout}ms`
+ );
+ if (isNaN(Number(process.env.NOVITA_LLM_TIMEOUT_MS)))
+ return this.defaultTimeout;
+ const setValue = Number(process.env.NOVITA_LLM_TIMEOUT_MS);
+ if (setValue < 500) return 500; // 500ms is the minimum timeout
+ return setValue;
+ }
+
+ // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis)
+ // from the current date. If it is, then we will refetch the API so that all the models are up
+ // to date.
+ #cacheIsStale() {
+ const MAX_STALE = 6.048e8; // 1 Week in MS
+ if (!fs.existsSync(this.cacheAtPath)) return true;
+ const now = Number(new Date());
+ const timestampMs = Number(fs.readFileSync(this.cacheAtPath));
+ return now - timestampMs > MAX_STALE;
+ }
+
+ // The Novita model API has a lot of models, so we cache this locally in the directory
+ // as if the cache directory JSON file is stale or does not exist we will fetch from API and store it.
+ // This might slow down the first request, but we need the proper token context window
+ // for each model and this is a constructor property - so we can really only get it if this cache exists.
+ // We used to have this as a chore, but given there is an API to get the info - this makes little sense.
+ async #syncModels() {
+ if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale())
+ return false;
+
+ this.log("Model cache is not present or stale. Fetching from Novita API.");
+ await fetchNovitaModels();
+ return;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ models() {
+ if (!fs.existsSync(this.cacheModelPath)) return {};
+ return safeJsonParse(
+ fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }),
+ {}
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ const cacheModelPath = path.resolve(cacheFolder, "models.json");
+ const availableModels = fs.existsSync(cacheModelPath)
+ ? safeJsonParse(
+ fs.readFileSync(cacheModelPath, { encoding: "utf-8" }),
+ {}
+ )
+ : {};
+ return availableModels[modelName]?.maxLength || 4096;
+ }
+
+ promptWindowLimit() {
+ const availableModels = this.models();
+ return availableModels[this.model]?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ await this.#syncModels();
+ const availableModels = this.models();
+ return availableModels.hasOwnProperty(model);
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Novita chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Novita chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ /**
+ * Handles the default stream response for a chat.
+ * @param {import("express").Response} response
+ * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream
+ * @param {Object} responseProps
+ * @returns {Promise}
+ */
+ handleStream(response, stream, responseProps) {
+ const timeoutThresholdMs = this.timeout;
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let lastChunkTime = null; // null when first token is still not received.
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ // NOTICE: Not all Novita models will return a stop reason
+ // which keeps the connection open and so the model never finalizes the stream
+ // like the traditional OpenAI response schema does. So in the case the response stream
+ // never reaches a formal close state we maintain an interval timer that if we go >=timeoutThresholdMs with
+ // no new chunks then we kill the stream and assume it to be complete. Novita is quite fast
+ // so this threshold should permit most responses, but we can adjust `timeoutThresholdMs` if
+ // we find it is too aggressive.
+ const timeoutCheck = setInterval(() => {
+ if (lastChunkTime === null) return;
+
+ const now = Number(new Date());
+ const diffMs = now - lastChunkTime;
+ if (diffMs >= timeoutThresholdMs) {
+ this.log(
+ `Novita stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.`
+ );
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ clearInterval(timeoutCheck);
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ }, 500);
+
+ try {
+ for await (const chunk of stream) {
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+ lastChunkTime = Number(new Date());
+
+ if (token) {
+ fullText += token;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+
+ if (message?.finish_reason !== null) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ }
+ } catch (e) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "abort",
+ textResponse: null,
+ close: true,
+ error: e.message,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+async function fetchNovitaModels() {
+ return await fetch(`https://api.novita.ai/v3/openai/models`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ })
+ .then((res) => res.json())
+ .then(({ data = [] }) => {
+ const models = {};
+ data.forEach((model) => {
+ models[model.id] = {
+ id: model.id,
+ name: model.title,
+ organization:
+ model.id.split("/")[0].charAt(0).toUpperCase() +
+ model.id.split("/")[0].slice(1),
+ maxLength: model.context_size,
+ };
+ });
+
+ // Cache all response information
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ fs.writeFileSync(
+ path.resolve(cacheFolder, "models.json"),
+ JSON.stringify(models),
+ {
+ encoding: "utf-8",
+ }
+ );
+ fs.writeFileSync(
+ path.resolve(cacheFolder, ".cached_at"),
+ String(Number(new Date())),
+ {
+ encoding: "utf-8",
+ }
+ );
+ return models;
+ })
+ .catch((e) => {
+ console.error(e);
+ return {};
+ });
+}
+
+module.exports = {
+ NovitaLLM,
+ fetchNovitaModels,
+};
diff --git a/server/utils/AiProviders/nvidiaNim/index.js b/server/utils/AiProviders/nvidiaNim/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..b421fdc15ba9a9fdb0080f9334a9ddcf84c4288a
--- /dev/null
+++ b/server/utils/AiProviders/nvidiaNim/index.js
@@ -0,0 +1,246 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+
+class NvidiaNimLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.NVIDIA_NIM_LLM_BASE_PATH)
+ throw new Error("No NVIDIA NIM API Base Path was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.nvidiaNim = new OpenAIApi({
+ baseURL: parseNvidiaNimBasePath(process.env.NVIDIA_NIM_LLM_BASE_PATH),
+ apiKey: null,
+ });
+
+ this.model = modelPreference || process.env.NVIDIA_NIM_LLM_MODEL_PREF;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.#log(
+ `Loaded with model: ${this.model} with context window: ${this.promptWindowLimit()}`
+ );
+ }
+
+ #log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ /**
+ * Set the model token limit `NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT` for the given model ID
+ * @param {string} modelId
+ * @param {string} basePath
+ * @returns {Promise}
+ */
+ static async setModelTokenLimit(modelId, basePath = null) {
+ if (!modelId) return;
+ const { OpenAI: OpenAIApi } = require("openai");
+ const openai = new OpenAIApi({
+ baseURL: parseNvidiaNimBasePath(
+ basePath || process.env.NVIDIA_NIM_LLM_BASE_PATH
+ ),
+ apiKey: null,
+ });
+ const model = await openai.models
+ .list()
+ .then((results) => results.data)
+ .catch(() => {
+ return [];
+ });
+
+ if (!model.length) return;
+ const modelInfo = model.find((model) => model.id === modelId);
+ if (!modelInfo) return;
+ process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT = Number(
+ modelInfo.max_model_len || 4096
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No NVIDIA NIM token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No NVIDIA NIM token context limit was set.");
+ return Number(limit);
+ }
+
+ async isValidChatCompletionModel(_ = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ `NVIDIA NIM chat: ${this.model} is not valid or defined model for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.nvidiaNim.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.model)
+ throw new Error(
+ `NVIDIA NIM chat: ${this.model} is not valid or defined model for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.nvidiaNim.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+/**
+ * Parse the base path for the Nvidia NIM container API. Since the base path must end in /v1 and cannot have a trailing slash,
+ * and the user can possibly set it to anything and likely incorrectly due to pasting behaviors, we need to ensure it is in the correct format.
+ * @param {string} basePath
+ * @returns {string}
+ */
+function parseNvidiaNimBasePath(providedBasePath = "") {
+ try {
+ const baseURL = new URL(providedBasePath);
+ const basePath = `${baseURL.origin}/v1`;
+ return basePath;
+ } catch (e) {
+ return providedBasePath;
+ }
+}
+
+module.exports = {
+ NvidiaNimLLM,
+ parseNvidiaNimBasePath,
+};
diff --git a/server/utils/AiProviders/ollama/README.md b/server/utils/AiProviders/ollama/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9e96b2ed07b3809ce2f3db2546826add0989637c
--- /dev/null
+++ b/server/utils/AiProviders/ollama/README.md
@@ -0,0 +1,40 @@
+# Common Issues with Ollama
+
+If you encounter an error stating `llama:streaming - could not stream chat. Error: connect ECONNREFUSED 172.17.0.1:11434` when using AnythingLLM in a Docker container, this indicates that the IP of the Host inside of the virtual docker network does not bind to port 11434 of the host system by default, due to Ollama's restriction to localhost and 127.0.0.1. To resolve this issue and ensure proper communication between the Dockerized AnythingLLM and the Ollama service, you must configure Ollama to bind to 0.0.0.0 or a specific IP address.
+
+### Setting Environment Variables on Mac
+
+If Ollama is run as a macOS application, environment variables should be set using `launchctl`:
+
+1. For each environment variable, call `launchctl setenv`.
+ ```bash
+ launchctl setenv OLLAMA_HOST "0.0.0.0"
+ ```
+2. Restart the Ollama application.
+
+### Setting Environment Variables on Linux
+
+If Ollama is run as a systemd service, environment variables should be set using `systemctl`:
+
+1. Edit the systemd service by calling `systemctl edit ollama.service`. This will open an editor.
+2. For each environment variable, add a line `Environment` under the section `[Service]`:
+ ```ini
+ [Service]
+ Environment="OLLAMA_HOST=0.0.0.0"
+ ```
+3. Save and exit.
+4. Reload `systemd` and restart Ollama:
+ ```bash
+ systemctl daemon-reload
+ systemctl restart ollama
+ ```
+
+### Setting Environment Variables on Windows
+
+On Windows, Ollama inherits your user and system environment variables.
+
+1. First, quit Ollama by clicking on it in the taskbar.
+2. Edit system environment variables from the Control Panel.
+3. Edit or create new variable(s) for your user account for `OLLAMA_HOST`, `OLLAMA_MODELS`, etc.
+4. Click OK/Apply to save.
+5. Run `ollama` from a new terminal window.
diff --git a/server/utils/AiProviders/ollama/index.js b/server/utils/AiProviders/ollama/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..d7c8b15e98c1b85435203b8d5753f88cde138943
--- /dev/null
+++ b/server/utils/AiProviders/ollama/index.js
@@ -0,0 +1,308 @@
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const { Ollama } = require("ollama");
+
+// Docs: https://github.com/jmorganca/ollama/blob/main/docs/api.md
+class OllamaAILLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.OLLAMA_BASE_PATH)
+ throw new Error("No Ollama Base Path was set.");
+
+ this.authToken = process.env.OLLAMA_AUTH_TOKEN;
+ this.basePath = process.env.OLLAMA_BASE_PATH;
+ this.model = modelPreference || process.env.OLLAMA_MODEL_PREF;
+ this.performanceMode = process.env.OLLAMA_PERFORMANCE_MODE || "base";
+ this.keepAlive = process.env.OLLAMA_KEEP_ALIVE_TIMEOUT
+ ? Number(process.env.OLLAMA_KEEP_ALIVE_TIMEOUT)
+ : 300; // Default 5-minute timeout for Ollama model loading.
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ const headers = this.authToken
+ ? { Authorization: `Bearer ${this.authToken}` }
+ : {};
+ this.client = new Ollama({ host: this.basePath, headers: headers });
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.#log(
+ `OllamaAILLM initialized with\nmodel: ${this.model}\nperf: ${this.performanceMode}\nn_ctx: ${this.promptWindowLimit()}`
+ );
+ }
+
+ #log(text, ...args) {
+ console.log(`\x1b[32m[Ollama]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.OLLAMA_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No Ollama token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.OLLAMA_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No Ollama token context limit was set.");
+ return Number(limit);
+ }
+
+ async isValidChatCompletionModel(_ = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {{content: string, images: string[]}}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) return { content: userPrompt };
+ const images = attachments.map(
+ (attachment) => attachment.contentString.split("base64,").slice(-1)[0]
+ );
+ return { content: userPrompt, images };
+ }
+
+ /**
+ * Handles errors from the Ollama API to make them more user friendly.
+ * @param {Error} e
+ */
+ #errorHandler(e) {
+ switch (e.message) {
+ case "fetch failed":
+ throw new Error(
+ "Your Ollama instance could not be reached or is not responding. Please make sure it is running the API server and your connection information is correct in AnythingLLM."
+ );
+ default:
+ return e;
+ }
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent, "spread"),
+ {
+ role: "user",
+ ...this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.client
+ .chat({
+ model: this.model,
+ stream: false,
+ messages,
+ keep_alive: this.keepAlive,
+ options: {
+ temperature,
+ use_mlock: true,
+ // There are currently only two performance settings so if its not "base" - its max context.
+ ...(this.performanceMode === "base"
+ ? {}
+ : { num_ctx: this.promptWindowLimit() }),
+ },
+ })
+ .then((res) => {
+ return {
+ content: res.message.content,
+ usage: {
+ prompt_tokens: res.prompt_eval_count,
+ completion_tokens: res.eval_count,
+ total_tokens: res.prompt_eval_count + res.eval_count,
+ },
+ };
+ })
+ .catch((e) => {
+ throw new Error(
+ `Ollama::getChatCompletion failed to communicate with Ollama. ${this.#errorHandler(e).message}`
+ );
+ })
+ );
+
+ if (!result.output.content || !result.output.content.length)
+ throw new Error(`Ollama::getChatCompletion text response was empty.`);
+
+ return {
+ textResponse: result.output.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens,
+ completion_tokens: result.output.usage.completion_tokens,
+ total_tokens: result.output.usage.total_tokens,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.client.chat({
+ model: this.model,
+ stream: true,
+ messages,
+ keep_alive: this.keepAlive,
+ options: {
+ temperature,
+ use_mlock: true,
+ // There are currently only two performance settings so if its not "base" - its max context.
+ ...(this.performanceMode === "base"
+ ? {}
+ : { num_ctx: this.promptWindowLimit() }),
+ },
+ }),
+ messages,
+ false
+ ).catch((e) => {
+ throw this.#errorHandler(e);
+ });
+ return measuredStreamRequest;
+ }
+
+ /**
+ * Handles streaming responses from Ollama.
+ * @param {import("express").Response} response
+ * @param {import("../../helpers/chat/LLMPerformanceMonitor").MonitoredStream} stream
+ * @param {import("express").Request} request
+ * @returns {Promise}
+ */
+ handleStream(response, stream, responseProps) {
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let usage = {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ };
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement(usage);
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ try {
+ for await (const chunk of stream) {
+ if (chunk === undefined)
+ throw new Error(
+ "Stream returned undefined chunk. Aborting reply - check model provider logs."
+ );
+
+ if (chunk.done) {
+ usage.prompt_tokens = chunk.prompt_eval_count;
+ usage.completion_tokens = chunk.eval_count;
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ break;
+ }
+
+ if (chunk.hasOwnProperty("message")) {
+ const content = chunk.message.content;
+ fullText += content;
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: content,
+ close: false,
+ error: false,
+ });
+ }
+ }
+ } catch (error) {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: `Ollama:streaming - could not stream chat. ${
+ error?.cause ?? error.message
+ }`,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ OllamaAILLM,
+};
diff --git a/server/utils/AiProviders/openAi/index.js b/server/utils/AiProviders/openAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..bc159d76f29e2e378b9471ff7f060d39243af4fa
--- /dev/null
+++ b/server/utils/AiProviders/openAi/index.js
@@ -0,0 +1,289 @@
+const { v4: uuidv4 } = require("uuid");
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ formatChatHistory,
+ writeResponseChunk,
+ clientAbortedHandler,
+} = require("../../helpers/chat/responses");
+const { MODEL_MAP } = require("../modelMap");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+class OpenAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.OPEN_AI_KEY) throw new Error("No OpenAI API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+
+ this.openai = new OpenAIApi({
+ apiKey: process.env.OPEN_AI_KEY,
+ });
+ this.model = modelPreference || process.env.OPEN_MODEL_PREF || "gpt-4o";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(
+ `Initialized ${this.model} with context window ${this.promptWindowLimit()}`
+ );
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ return MODEL_MAP.get("openai", modelName) ?? 4_096;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.get("openai", this.model) ?? 4_096;
+ }
+
+ // Short circuit if name has 'gpt' since we now fetch models from OpenAI API
+ // via the user API key, so the model must be relevant and real.
+ // and if somehow it is not, chat will fail but that is caught.
+ // we don't want to hit the OpenAI api every chat because it will get spammed
+ // and introduce latency for no reason.
+ async isValidChatCompletionModel(modelName = "") {
+ const isPreset =
+ modelName.toLowerCase().includes("gpt") ||
+ modelName.toLowerCase().startsWith("o");
+ if (isPreset) return true;
+
+ const model = await this.openai.models
+ .retrieve(modelName)
+ .then((modelObj) => modelObj)
+ .catch(() => null);
+ return !!model;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "input_text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "input_image",
+ image_url: attachment.contentString,
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ /**
+ * Determine the appropriate temperature for the model.
+ * @param {string} modelName
+ * @param {number} temperature
+ * @returns {number}
+ */
+ #temperature(modelName, temperature) {
+ // For models that don't support temperature
+ // OpenAI accepts temperature 1
+ const NO_TEMP_MODELS = ["o", "gpt-5"];
+
+ if (NO_TEMP_MODELS.some((prefix) => modelName.startsWith(prefix))) {
+ return 1;
+ }
+
+ return temperature;
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `OpenAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.responses
+ .create({
+ model: this.model,
+ input: messages,
+ store: false,
+ temperature: this.#temperature(this.model, temperature),
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (!result.output.hasOwnProperty("output_text")) return null;
+
+ const usage = result.output.usage || {};
+ return {
+ textResponse: result.output.output_text,
+ metrics: {
+ prompt_tokens: usage.prompt_tokens || 0,
+ completion_tokens: usage.completion_tokens || 0,
+ total_tokens: usage.total_tokens || 0,
+ outputTps: usage.completion_tokens
+ ? usage.completion_tokens / result.duration
+ : 0,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `OpenAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.responses.create({
+ model: this.model,
+ stream: true,
+ input: messages,
+ store: false,
+ temperature: this.#temperature(this.model, temperature),
+ }),
+ messages,
+ false
+ );
+
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+
+ let hasUsageMetrics = false;
+ let usage = {
+ completion_tokens: 0,
+ };
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+
+ const handleAbort = () => {
+ stream?.endMeasurement(usage);
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ try {
+ for await (const chunk of stream) {
+ if (chunk.type === "response.output_text.delta") {
+ const token = chunk.delta;
+ if (token) {
+ fullText += token;
+ if (!hasUsageMetrics) usage.completion_tokens++;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: token,
+ close: false,
+ error: false,
+ });
+ }
+ } else if (chunk.type === "response.completed") {
+ const { response: res } = chunk;
+ if (res.hasOwnProperty("usage") && !!res.usage) {
+ hasUsageMetrics = true;
+ usage = { ...usage, ...res.usage };
+ }
+
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ break;
+ }
+ }
+ } catch (e) {
+ console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`);
+ writeResponseChunk(response, {
+ uuid,
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ OpenAiLLM,
+};
diff --git a/server/utils/AiProviders/openRouter/index.js b/server/utils/AiProviders/openRouter/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..ea1665c04db9dbcd7c99b935d18dac86f5f46775
--- /dev/null
+++ b/server/utils/AiProviders/openRouter/index.js
@@ -0,0 +1,556 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const { v4: uuidv4 } = require("uuid");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const fs = require("fs");
+const path = require("path");
+const { safeJsonParse } = require("../../http");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const cacheFolder = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "openrouter")
+ : path.resolve(__dirname, `../../../storage/models/openrouter`)
+);
+
+class OpenRouterLLM {
+ /**
+ * Some openrouter models never send a finish_reason and thus leave the stream open in the UI.
+ * However, because OR is a middleware it can also wait an inordinately long time between chunks so we need
+ * to ensure that we dont accidentally close the stream too early. If the time between chunks is greater than this timeout
+ * we will close the stream and assume it to be complete. This is common for free models or slow providers they can
+ * possibly delegate to during invocation.
+ * @type {number}
+ */
+ defaultTimeout = 3_000;
+
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.OPENROUTER_API_KEY)
+ throw new Error("No OpenRouter API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.basePath = "https://openrouter.ai/api/v1";
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.OPENROUTER_API_KEY ?? null,
+ defaultHeaders: {
+ "HTTP-Referer": "https://anythingllm.com",
+ "X-Title": "AnythingLLM",
+ },
+ });
+ this.model =
+ modelPreference || process.env.OPENROUTER_MODEL_PREF || "openrouter/auto";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.timeout = this.#parseTimeout();
+
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ this.cacheModelPath = path.resolve(cacheFolder, "models.json");
+ this.cacheAtPath = path.resolve(cacheFolder, ".cached_at");
+ this.log("Initialized with model:", this.model);
+ }
+
+ /**
+ * Returns true if the model is a Perplexity model.
+ * OpenRouter has support for a lot of models and we have some special handling for Perplexity models
+ * that support in-line citations.
+ * @returns {boolean}
+ */
+ get isPerplexityModel() {
+ return this.model.startsWith("perplexity/");
+ }
+
+ /**
+ * Generic formatting of a token for the following use cases:
+ * - Perplexity models that return inline citations in the token text
+ * @param {{token: string, citations: string[]}} options - The token text and citations.
+ * @returns {string} - The formatted token text.
+ */
+ enrichToken({ token, citations = [] }) {
+ if (!Array.isArray(citations) || citations.length === 0) return token;
+ return token.replace(/\[(\d+)\]/g, (match, index) => {
+ const citationIndex = parseInt(index) - 1;
+ return citations[citationIndex]
+ ? `[[${index}](${citations[citationIndex]})]`
+ : match;
+ });
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * OpenRouter has various models that never return `finish_reasons` and thus leave the stream open
+ * which causes issues in subsequent messages. This timeout value forces us to close the stream after
+ * x milliseconds. This is a configurable value via the OPENROUTER_TIMEOUT_MS value
+ * @returns {number} The timeout value in milliseconds (default: 3_000)
+ */
+ #parseTimeout() {
+ this.log(
+ `OpenRouter timeout is set to ${process.env.OPENROUTER_TIMEOUT_MS ?? this.defaultTimeout}ms`
+ );
+ if (isNaN(Number(process.env.OPENROUTER_TIMEOUT_MS)))
+ return this.defaultTimeout;
+ const setValue = Number(process.env.OPENROUTER_TIMEOUT_MS);
+ if (setValue < 500) return 500; // 500ms is the minimum timeout
+ return setValue;
+ }
+
+ // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis)
+ // from the current date. If it is, then we will refetch the API so that all the models are up
+ // to date.
+ #cacheIsStale() {
+ const MAX_STALE = 6.048e8; // 1 Week in MS
+ if (!fs.existsSync(this.cacheAtPath)) return true;
+ const now = Number(new Date());
+ const timestampMs = Number(fs.readFileSync(this.cacheAtPath));
+ return now - timestampMs > MAX_STALE;
+ }
+
+ // The OpenRouter model API has a lot of models, so we cache this locally in the directory
+ // as if the cache directory JSON file is stale or does not exist we will fetch from API and store it.
+ // This might slow down the first request, but we need the proper token context window
+ // for each model and this is a constructor property - so we can really only get it if this cache exists.
+ // We used to have this as a chore, but given there is an API to get the info - this makes little sense.
+ async #syncModels() {
+ if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale())
+ return false;
+
+ this.log(
+ "Model cache is not present or stale. Fetching from OpenRouter API."
+ );
+ await fetchOpenRouterModels();
+ return;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ models() {
+ if (!fs.existsSync(this.cacheModelPath)) return {};
+ return safeJsonParse(
+ fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }),
+ {}
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ const cacheModelPath = path.resolve(cacheFolder, "models.json");
+ const availableModels = fs.existsSync(cacheModelPath)
+ ? safeJsonParse(
+ fs.readFileSync(cacheModelPath, { encoding: "utf-8" }),
+ {}
+ )
+ : {};
+ return availableModels[modelName]?.maxLength || 4096;
+ }
+
+ promptWindowLimit() {
+ const availableModels = this.models();
+ return availableModels[this.model]?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ await this.#syncModels();
+ const availableModels = this.models();
+ return availableModels.hasOwnProperty(model);
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Parses and prepends reasoning from the response and returns the full text response.
+ * @param {Object} response
+ * @returns {string}
+ */
+ #parseReasoningFromResponse({ message }) {
+ let textResponse = message?.content;
+ if (!!message?.reasoning && message.reasoning.trim().length > 0)
+ textResponse = `${message.reasoning} ${textResponse}`;
+ return textResponse;
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `OpenRouter chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ // This is an OpenRouter specific option that allows us to get the reasoning text
+ // before the token text.
+ include_reasoning: true,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result?.output?.hasOwnProperty("choices") ||
+ result?.output?.choices?.length === 0
+ )
+ throw new Error(
+ `Invalid response body returned from OpenRouter: ${result.output?.error?.message || "Unknown error"} ${result.output?.error?.code || "Unknown code"}`
+ );
+
+ return {
+ textResponse: this.#parseReasoningFromResponse(result.output.choices[0]),
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `OpenRouter chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ // This is an OpenRouter specific option that allows us to get the reasoning text
+ // before the token text.
+ include_reasoning: true,
+ }),
+ messages
+ // We have to manually count the tokens
+ // OpenRouter has a ton of providers and they all can return slightly differently
+ // some return chunk.usage on STOP, some do it after stop, its inconsistent.
+ // So it is possible reported metrics are inaccurate since we cannot reliably
+ // catch the metrics before resolving the stream - so we just pretend this functionality
+ // is not available.
+ );
+
+ return measuredStreamRequest;
+ }
+
+ /**
+ * Handles the default stream response for a chat.
+ * @param {import("express").Response} response
+ * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream
+ * @param {Object} responseProps
+ * @returns {Promise}
+ */
+ handleStream(response, stream, responseProps) {
+ const timeoutThresholdMs = this.timeout;
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let reasoningText = "";
+ let lastChunkTime = null; // null when first token is still not received.
+ let pplxCitations = []; // Array of inline citations for Perplexity models (if applicable)
+ let isPerplexity = this.isPerplexityModel;
+
+ // Establish listener to early-abort a streaming response
+ // in case things go sideways or the user does not like the response.
+ // We preserve the generated text but continue as if chat was completed
+ // to preserve previously generated content.
+ const handleAbort = () => {
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ // NOTICE: Not all OpenRouter models will return a stop reason
+ // which keeps the connection open and so the model never finalizes the stream
+ // like the traditional OpenAI response schema does. So in the case the response stream
+ // never reaches a formal close state we maintain an interval timer that if we go >=timeoutThresholdMs with
+ // no new chunks then we kill the stream and assume it to be complete. OpenRouter is quite fast
+ // so this threshold should permit most responses, but we can adjust `timeoutThresholdMs` if
+ // we find it is too aggressive.
+ const timeoutCheck = setInterval(() => {
+ if (lastChunkTime === null) return;
+
+ const now = Number(new Date());
+ const diffMs = now - lastChunkTime;
+
+ if (diffMs >= timeoutThresholdMs) {
+ console.log(
+ `OpenRouter stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.`
+ );
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ clearInterval(timeoutCheck);
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ }, 500);
+
+ try {
+ for await (const chunk of stream) {
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+ const reasoningToken = message?.delta?.reasoning;
+ lastChunkTime = Number(new Date());
+
+ // Some models will return citations (e.g. Perplexity) - we should preserve them for inline citations if applicable.
+ if (
+ isPerplexity &&
+ Array.isArray(chunk?.citations) &&
+ chunk?.citations?.length !== 0
+ )
+ pplxCitations.push(...chunk.citations);
+
+ // Reasoning models will always return the reasoning text before the token text.
+ // can be null or ''
+ if (reasoningToken) {
+ const formattedReasoningToken = this.enrichToken({
+ token: reasoningToken,
+ citations: pplxCitations,
+ });
+
+ // If the reasoning text is empty (''), we need to initialize it
+ // and send the first chunk of reasoning text.
+ if (reasoningText.length === 0) {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: `${formattedReasoningToken}`,
+ close: false,
+ error: false,
+ });
+ reasoningText += `${formattedReasoningToken}`;
+ continue;
+ } else {
+ // If the reasoning text is not empty, we need to append the reasoning text
+ // to the existing reasoning text.
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: formattedReasoningToken,
+ close: false,
+ error: false,
+ });
+ reasoningText += formattedReasoningToken;
+ }
+ }
+
+ // If the reasoning text is not empty, but the reasoning token is empty
+ // and the token text is not empty we need to close the reasoning text and begin sending the token text.
+ if (!!reasoningText && !reasoningToken && token) {
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: ` `,
+ close: false,
+ error: false,
+ });
+ fullText += `${reasoningText} `;
+ reasoningText = "";
+ }
+
+ if (token) {
+ const formattedToken = this.enrichToken({
+ token,
+ citations: pplxCitations,
+ });
+ fullText += formattedToken;
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: formattedToken,
+ close: false,
+ error: false,
+ });
+ }
+
+ if (message.finish_reason !== null) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ clearInterval(timeoutCheck);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ }
+ } catch (e) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "abort",
+ textResponse: null,
+ close: true,
+ error: e.message,
+ });
+ response.removeListener("close", handleAbort);
+ clearInterval(timeoutCheck);
+ stream?.endMeasurement({
+ completion_tokens: LLMPerformanceMonitor.countTokens(fullText),
+ });
+ resolve(fullText);
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+async function fetchOpenRouterModels() {
+ return await fetch(`https://openrouter.ai/api/v1/models`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ })
+ .then((res) => res.json())
+ .then(({ data = [] }) => {
+ const models = {};
+ data.forEach((model) => {
+ models[model.id] = {
+ id: model.id,
+ name: model.name,
+ organization:
+ model.id.split("/")[0].charAt(0).toUpperCase() +
+ model.id.split("/")[0].slice(1),
+ maxLength: model.context_length,
+ };
+ });
+
+ // Cache all response information
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ fs.writeFileSync(
+ path.resolve(cacheFolder, "models.json"),
+ JSON.stringify(models),
+ {
+ encoding: "utf-8",
+ }
+ );
+ fs.writeFileSync(
+ path.resolve(cacheFolder, ".cached_at"),
+ String(Number(new Date())),
+ {
+ encoding: "utf-8",
+ }
+ );
+
+ return models;
+ })
+ .catch((e) => {
+ console.error(e);
+ return {};
+ });
+}
+
+module.exports = {
+ OpenRouterLLM,
+ fetchOpenRouterModels,
+};
diff --git a/server/utils/AiProviders/perplexity/index.js b/server/utils/AiProviders/perplexity/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..5007c4c5eb08d5b3e3e601a6e585b1c2c6dabf08
--- /dev/null
+++ b/server/utils/AiProviders/perplexity/index.js
@@ -0,0 +1,297 @@
+const { v4: uuidv4 } = require("uuid");
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+} = require("../../helpers/chat/responses");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+function perplexityModels() {
+ const { MODELS } = require("./models.js");
+ return MODELS || {};
+}
+
+class PerplexityLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.PERPLEXITY_API_KEY)
+ throw new Error("No Perplexity API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.perplexity.ai",
+ apiKey: process.env.PERPLEXITY_API_KEY ?? null,
+ });
+ this.model =
+ modelPreference ||
+ process.env.PERPLEXITY_MODEL_PREF ||
+ "llama-3-sonar-large-32k-online"; // Give at least a unique model to the provider as last fallback.
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ allModelInformation() {
+ return perplexityModels();
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ const availableModels = perplexityModels();
+ return availableModels[modelName]?.maxLength || 4096;
+ }
+
+ promptWindowLimit() {
+ const availableModels = this.allModelInformation();
+ return availableModels[this.model]?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ const availableModels = this.allModelInformation();
+ return availableModels.hasOwnProperty(model);
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [prompt, ...chatHistory, { role: "user", content: userPrompt }];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Perplexity chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps: result.output.usage?.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `Perplexity chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ /**
+ * Enrich a token with citations if available for in-line citations.
+ * @param {string} token - The token to enrich.
+ * @param {Array} citations - The citations to enrich the token with.
+ * @returns {string} The enriched token.
+ */
+ enrichToken(token, citations) {
+ if (!Array.isArray(citations) || citations.length === 0) return token;
+ return token.replace(/\[(\d+)\]/g, (match, index) => {
+ const citationIndex = parseInt(index) - 1;
+ return citations[citationIndex]
+ ? `[[${index}](${citations[citationIndex]})]`
+ : match;
+ });
+ }
+
+ handleStream(response, stream, responseProps) {
+ const timeoutThresholdMs = 800;
+ const { uuid = uuidv4(), sources = [] } = responseProps;
+ let hasUsageMetrics = false;
+ let pplxCitations = []; // Array of links
+ let usage = {
+ completion_tokens: 0,
+ };
+
+ return new Promise(async (resolve) => {
+ let fullText = "";
+ let lastChunkTime = null;
+
+ const handleAbort = () => {
+ stream?.endMeasurement(usage);
+ clientAbortedHandler(resolve, fullText);
+ };
+ response.on("close", handleAbort);
+
+ const timeoutCheck = setInterval(() => {
+ if (lastChunkTime === null) return;
+
+ const now = Number(new Date());
+ const diffMs = now - lastChunkTime;
+ if (diffMs >= timeoutThresholdMs) {
+ console.log(
+ `Perplexity stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.`
+ );
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ clearInterval(timeoutCheck);
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ resolve(fullText);
+ }
+ }, 500);
+
+ // Now handle the chunks from the streamed response and append to fullText.
+ try {
+ for await (const chunk of stream) {
+ lastChunkTime = Number(new Date());
+ const message = chunk?.choices?.[0];
+ const token = message?.delta?.content;
+
+ if (Array.isArray(chunk.citations) && chunk.citations.length !== 0) {
+ pplxCitations = chunk.citations;
+ }
+
+ // If we see usage metrics in the chunk, we can use them directly
+ // instead of estimating them, but we only want to assign values if
+ // the response object is the exact same key:value pair we expect.
+ if (
+ chunk.hasOwnProperty("usage") && // exists
+ !!chunk.usage && // is not null
+ Object.values(chunk.usage).length > 0 // has values
+ ) {
+ if (chunk.usage.hasOwnProperty("prompt_tokens")) {
+ usage.prompt_tokens = Number(chunk.usage.prompt_tokens);
+ }
+
+ if (chunk.usage.hasOwnProperty("completion_tokens")) {
+ hasUsageMetrics = true; // to stop estimating counter
+ usage.completion_tokens = Number(chunk.usage.completion_tokens);
+ }
+ }
+
+ if (token) {
+ let enrichedToken = this.enrichToken(token, pplxCitations);
+ fullText += enrichedToken;
+ if (!hasUsageMetrics) usage.completion_tokens++;
+
+ writeResponseChunk(response, {
+ uuid,
+ sources: [],
+ type: "textResponseChunk",
+ textResponse: enrichedToken,
+ close: false,
+ error: false,
+ });
+ }
+
+ if (message?.finish_reason) {
+ console.log("closing");
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "textResponseChunk",
+ textResponse: "",
+ close: true,
+ error: false,
+ });
+ response.removeListener("close", handleAbort);
+ stream?.endMeasurement(usage);
+ clearInterval(timeoutCheck);
+ resolve(fullText);
+ break; // Break streaming when a valid finish_reason is first encountered
+ }
+ }
+ } catch (e) {
+ console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`);
+ writeResponseChunk(response, {
+ uuid,
+ type: "abort",
+ textResponse: null,
+ sources: [],
+ close: true,
+ error: e.message,
+ });
+ stream?.endMeasurement(usage);
+ clearInterval(timeoutCheck);
+ resolve(fullText); // Return what we currently have - if anything.
+ }
+ });
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ PerplexityLLM,
+ perplexityModels,
+};
diff --git a/server/utils/AiProviders/perplexity/models.js b/server/utils/AiProviders/perplexity/models.js
new file mode 100644
index 0000000000000000000000000000000000000000..f035ae28d66617370f36ecaca0696fe82b5c895e
--- /dev/null
+++ b/server/utils/AiProviders/perplexity/models.js
@@ -0,0 +1,24 @@
+const MODELS = {
+ "sonar-reasoning-pro": {
+ id: "sonar-reasoning-pro",
+ name: "sonar-reasoning-pro",
+ maxLength: 127072,
+ },
+ "sonar-reasoning": {
+ id: "sonar-reasoning",
+ name: "sonar-reasoning",
+ maxLength: 127072,
+ },
+ "sonar-pro": {
+ id: "sonar-pro",
+ name: "sonar-pro",
+ maxLength: 200000,
+ },
+ sonar: {
+ id: "sonar",
+ name: "sonar",
+ maxLength: 127072,
+ },
+};
+
+module.exports.MODELS = MODELS;
diff --git a/server/utils/AiProviders/perplexity/scripts/.gitignore b/server/utils/AiProviders/perplexity/scripts/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..94a2dd146a22340832c88013e9fe92663bb9f2cc
--- /dev/null
+++ b/server/utils/AiProviders/perplexity/scripts/.gitignore
@@ -0,0 +1 @@
+*.json
\ No newline at end of file
diff --git a/server/utils/AiProviders/perplexity/scripts/chat_models.txt b/server/utils/AiProviders/perplexity/scripts/chat_models.txt
new file mode 100644
index 0000000000000000000000000000000000000000..44ab9ac300c8bcbccc6d641f1384ea0935a82f0e
--- /dev/null
+++ b/server/utils/AiProviders/perplexity/scripts/chat_models.txt
@@ -0,0 +1,6 @@
+| Model | Parameter Count | Context Length | Model Type |
+| :---------------------------------- | :-------------- | :------------- | :-------------- |
+| `sonar-reasoning-pro` | 8B | 127,072 | Chat Completion |
+| `sonar-reasoning` | 8B | 127,072 | Chat Completion |
+| `sonar-pro` | 8B | 200,000 | Chat Completion |
+| `sonar` | 8B | 127,072 | Chat Completion |
\ No newline at end of file
diff --git a/server/utils/AiProviders/perplexity/scripts/parse.mjs b/server/utils/AiProviders/perplexity/scripts/parse.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..a5ba3af732c4e9bec8a5006f6fe7ac3f9ef7163c
--- /dev/null
+++ b/server/utils/AiProviders/perplexity/scripts/parse.mjs
@@ -0,0 +1,49 @@
+// Perplexity does not provide a simple REST API to get models,
+// so we have a table which we copy from their documentation
+// https://docs.perplexity.ai/edit/model-cards that we can
+// then parse and get all models from in a format that makes sense
+// Why this does not exist is so bizarre, but whatever.
+
+// To run, cd into this directory and run `node parse.mjs`
+// copy outputs into the export in ../models.js
+
+// Update the date below if you run this again because Perplexity added new models.
+// Last Collected: Jan 23, 2025
+
+// UPDATE: Jan 23, 2025
+// The table is no longer available on the website, but Perplexity has deprecated the
+// old models so now we can just update the chat_models.txt file with the new models
+// manually and then run this script to get the new models.
+
+import fs from "fs";
+
+function parseChatModels() {
+ const models = {};
+ const tableString = fs.readFileSync("chat_models.txt", { encoding: "utf-8" });
+ const rows = tableString.split("\n").slice(2);
+
+ rows.forEach((row) => {
+ let [model, _, contextLength] = row
+ .split("|")
+ .slice(1, -1)
+ .map((text) => text.trim());
+ model = model.replace(/`|\s*\[\d+\]\s*/g, "");
+ const maxLength = Number(contextLength.replace(/[^\d]/g, ""));
+ if (model && maxLength) {
+ models[model] = {
+ id: model,
+ name: model,
+ maxLength: maxLength,
+ };
+ }
+ });
+
+ fs.writeFileSync(
+ "chat_models.json",
+ JSON.stringify(models, null, 2),
+ "utf-8"
+ );
+ return models;
+}
+
+parseChatModels();
diff --git a/server/utils/AiProviders/ppio/index.js b/server/utils/AiProviders/ppio/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..677cd4cd04fcd2a0505c3557808f80259cfd91fe
--- /dev/null
+++ b/server/utils/AiProviders/ppio/index.js
@@ -0,0 +1,266 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+const fs = require("fs");
+const path = require("path");
+const { safeJsonParse } = require("../../http");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const cacheFolder = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "ppio")
+ : path.resolve(__dirname, `../../../storage/models/ppio`)
+);
+
+class PPIOLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.PPIO_API_KEY) throw new Error("No PPIO API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.basePath = "https://api.ppinfra.com/v3/openai/";
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.PPIO_API_KEY ?? null,
+ defaultHeaders: {
+ "HTTP-Referer": "https://anythingllm.com",
+ "X-API-Source": "anythingllm",
+ },
+ });
+ this.model =
+ modelPreference ||
+ process.env.PPIO_MODEL_PREF ||
+ "qwen/qwen2.5-32b-instruct";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ this.cacheModelPath = path.resolve(cacheFolder, "models.json");
+ this.cacheAtPath = path.resolve(cacheFolder, ".cached_at");
+
+ this.log(`Loaded with model: ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ async #syncModels() {
+ if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale())
+ return false;
+
+ this.log("Model cache is not present or stale. Fetching from PPIO API.");
+ await fetchPPIOModels();
+ return;
+ }
+
+ #cacheIsStale() {
+ const MAX_STALE = 6.048e8; // 1 Week in MS
+ if (!fs.existsSync(this.cacheAtPath)) return true;
+ const now = Number(new Date());
+ const timestampMs = Number(fs.readFileSync(this.cacheAtPath));
+ return now - timestampMs > MAX_STALE;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ models() {
+ if (!fs.existsSync(this.cacheModelPath)) return {};
+ return safeJsonParse(
+ fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }),
+ {}
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ promptWindowLimit() {
+ const model = this.models()[this.model];
+ if (!model) return 4096; // Default to 4096 if we cannot find the model
+ return model?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ await this.#syncModels();
+ const availableModels = this.models();
+ return Object.prototype.hasOwnProperty.call(availableModels, model);
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "auto",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ // attachments = [], - not supported
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [prompt, ...chatHistory, { role: "user", content: userPrompt }];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `PPIO chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !Object.prototype.hasOwnProperty.call(result.output, "choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `PPIO chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+async function fetchPPIOModels() {
+ return await fetch(`https://api.ppinfra.com/v3/openai/models`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.PPIO_API_KEY}`,
+ },
+ })
+ .then((res) => res.json())
+ .then(({ data = [] }) => {
+ const models = {};
+ data.forEach((model) => {
+ const organization = model.id?.split("/")?.[0] || "PPIO";
+ models[model.id] = {
+ id: model.id,
+ name: model.display_name || model.title || model.id,
+ organization,
+ maxLength: model.context_size || 4096,
+ };
+ });
+
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ fs.writeFileSync(
+ path.resolve(cacheFolder, "models.json"),
+ JSON.stringify(models),
+ {
+ encoding: "utf-8",
+ }
+ );
+ fs.writeFileSync(
+ path.resolve(cacheFolder, ".cached_at"),
+ String(Number(new Date())),
+ {
+ encoding: "utf-8",
+ }
+ );
+ return models;
+ })
+ .catch((e) => {
+ console.error(e);
+ return {};
+ });
+}
+
+module.exports = {
+ PPIOLLM,
+ fetchPPIOModels,
+};
diff --git a/server/utils/AiProviders/textGenWebUI/index.js b/server/utils/AiProviders/textGenWebUI/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f3647c06d455f230d32d5ffa79218f9557fc5ba6
--- /dev/null
+++ b/server/utils/AiProviders/textGenWebUI/index.js
@@ -0,0 +1,190 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+
+class TextGenWebUILLM {
+ constructor(embedder = null) {
+ const { OpenAI: OpenAIApi } = require("openai");
+ if (!process.env.TEXT_GEN_WEB_UI_BASE_PATH)
+ throw new Error(
+ "TextGenWebUI must have a valid base path to use for the api."
+ );
+
+ this.basePath = process.env.TEXT_GEN_WEB_UI_BASE_PATH;
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.TEXT_GEN_WEB_UI_API_KEY ?? null,
+ });
+ this.model = null;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(`Inference API: ${this.basePath} Model: ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(_modelName) {
+ const limit = process.env.TEXT_GEN_WEB_UI_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Ensure the user set a value for the token limit
+ // and if undefined - assume 4096 window.
+ promptWindowLimit() {
+ const limit = process.env.TEXT_GEN_WEB_UI_MODEL_TOKEN_LIMIT || 4096;
+ if (!limit || isNaN(Number(limit)))
+ throw new Error("No token context limit was set.");
+ return Number(limit);
+ }
+
+ // Short circuit since we have no idea if the model is valid or not
+ // in pre-flight for generic endpoints
+ isValidChatCompletionModel(_modelName = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps: result.output.usage?.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ TextGenWebUILLM,
+};
diff --git a/server/utils/AiProviders/togetherAi/index.js b/server/utils/AiProviders/togetherAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..00ad4e647f1f154263726a003a53ba149f2d9ed2
--- /dev/null
+++ b/server/utils/AiProviders/togetherAi/index.js
@@ -0,0 +1,257 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const fs = require("fs");
+const path = require("path");
+const { safeJsonParse } = require("../../http");
+
+const cacheFolder = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "models", "togetherAi")
+ : path.resolve(__dirname, `../../../storage/models/togetherAi`)
+);
+
+async function togetherAiModels(apiKey = null) {
+ const cacheModelPath = path.resolve(cacheFolder, "models.json");
+ const cacheAtPath = path.resolve(cacheFolder, ".cached_at");
+
+ // If cache exists and is less than 1 week old, use it
+ if (fs.existsSync(cacheModelPath) && fs.existsSync(cacheAtPath)) {
+ const now = Number(new Date());
+ const timestampMs = Number(fs.readFileSync(cacheAtPath));
+ if (now - timestampMs <= 6.048e8) {
+ // 1 Week in MS
+ return safeJsonParse(
+ fs.readFileSync(cacheModelPath, { encoding: "utf-8" }),
+ []
+ );
+ }
+ }
+
+ try {
+ const { OpenAI: OpenAIApi } = require("openai");
+ const openai = new OpenAIApi({
+ baseURL: "https://api.together.xyz/v1",
+ apiKey: apiKey || process.env.TOGETHER_AI_API_KEY || null,
+ });
+
+ const response = await openai.models.list();
+
+ // Filter and transform models into the expected format
+ // Only include chat models
+ const validModels = response.body
+ .filter((model) => ["chat"].includes(model.type))
+ .map((model) => ({
+ id: model.id,
+ name: model.display_name || model.id,
+ organization: model.organization || "Unknown",
+ type: model.type,
+ maxLength: model.context_length || 4096,
+ }));
+
+ // Cache the results
+ if (!fs.existsSync(cacheFolder))
+ fs.mkdirSync(cacheFolder, { recursive: true });
+ fs.writeFileSync(cacheModelPath, JSON.stringify(validModels), {
+ encoding: "utf-8",
+ });
+ fs.writeFileSync(cacheAtPath, String(Number(new Date())), {
+ encoding: "utf-8",
+ });
+
+ return validModels;
+ } catch (error) {
+ console.error("Error fetching Together AI models:", error);
+ // If cache exists but is stale, still use it as fallback
+ if (fs.existsSync(cacheModelPath)) {
+ return safeJsonParse(
+ fs.readFileSync(cacheModelPath, { encoding: "utf-8" }),
+ []
+ );
+ }
+ return [];
+ }
+}
+
+class TogetherAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.TOGETHER_AI_API_KEY)
+ throw new Error("No TogetherAI API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.together.xyz/v1",
+ apiKey: process.env.TOGETHER_AI_API_KEY ?? null,
+ });
+ this.model = modelPreference || process.env.TOGETHER_AI_MODEL_PREF;
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = !embedder ? new NativeEmbedder() : embedder;
+ this.defaultTemp = 0.7;
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ async allModelInformation() {
+ const models = await togetherAiModels();
+ return models.reduce((acc, model) => {
+ acc[model.id] = model;
+ return acc;
+ }, {});
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static async promptWindowLimit(modelName) {
+ const models = await togetherAiModels();
+ const model = models.find((m) => m.id === modelName);
+ return model?.maxLength || 4096;
+ }
+
+ async promptWindowLimit() {
+ const models = await togetherAiModels();
+ const model = models.find((m) => m.id === this.model);
+ return model?.maxLength || 4096;
+ }
+
+ async isValidChatCompletionModel(model = "") {
+ const models = await togetherAiModels();
+ const foundModel = models.find((m) => m.id === model);
+ return foundModel && foundModel.type === "chat";
+ }
+
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [],
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...chatHistory,
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `TogetherAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage?.prompt_tokens || 0,
+ completion_tokens: result.output.usage?.completion_tokens || 0,
+ total_tokens: result.output.usage?.total_tokens || 0,
+ outputTps: result.output.usage?.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!(await this.isValidChatCompletionModel(this.model)))
+ throw new Error(
+ `TogetherAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages,
+ false
+ );
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ TogetherAiLLM,
+ togetherAiModels,
+};
diff --git a/server/utils/AiProviders/xai/index.js b/server/utils/AiProviders/xai/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f911627094646682ecf2233bde35d0a7a14d683b
--- /dev/null
+++ b/server/utils/AiProviders/xai/index.js
@@ -0,0 +1,194 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ LLMPerformanceMonitor,
+} = require("../../helpers/chat/LLMPerformanceMonitor");
+const {
+ handleDefaultStreamResponseV2,
+ formatChatHistory,
+} = require("../../helpers/chat/responses");
+const { MODEL_MAP } = require("../modelMap");
+
+class XAiLLM {
+ constructor(embedder = null, modelPreference = null) {
+ if (!process.env.XAI_LLM_API_KEY)
+ throw new Error("No xAI API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.x.ai/v1",
+ apiKey: process.env.XAI_LLM_API_KEY,
+ });
+ this.model =
+ modelPreference || process.env.XAI_LLM_MODEL_PREF || "grok-beta";
+ this.limits = {
+ history: this.promptWindowLimit() * 0.15,
+ system: this.promptWindowLimit() * 0.15,
+ user: this.promptWindowLimit() * 0.7,
+ };
+
+ this.embedder = embedder ?? new NativeEmbedder();
+ this.defaultTemp = 0.7;
+ this.log(
+ `Initialized ${this.model} with context window ${this.promptWindowLimit()}`
+ );
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ #appendContext(contextTexts = []) {
+ if (!contextTexts || !contextTexts.length) return "";
+ return (
+ "\nContext:\n" +
+ contextTexts
+ .map((text, i) => {
+ return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`;
+ })
+ .join("")
+ );
+ }
+
+ streamingEnabled() {
+ return "streamGetChatCompletion" in this;
+ }
+
+ static promptWindowLimit(modelName) {
+ return MODEL_MAP.get("xai", modelName) ?? 131_072;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.get("xai", this.model) ?? 131_072;
+ }
+
+ isValidChatCompletionModel(_modelName = "") {
+ return true;
+ }
+
+ /**
+ * Generates appropriate content array for a message + attachments.
+ * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}}
+ * @returns {string|object[]}
+ */
+ #generateContent({ userPrompt, attachments = [] }) {
+ if (!attachments.length) {
+ return userPrompt;
+ }
+
+ const content = [{ type: "text", text: userPrompt }];
+ for (let attachment of attachments) {
+ content.push({
+ type: "image_url",
+ image_url: {
+ url: attachment.contentString,
+ detail: "high",
+ },
+ });
+ }
+ return content.flat();
+ }
+
+ /**
+ * Construct the user prompt for this model.
+ * @param {{attachments: import("../../helpers").Attachment[]}} param0
+ * @returns
+ */
+ constructPrompt({
+ systemPrompt = "",
+ contextTexts = [],
+ chatHistory = [],
+ userPrompt = "",
+ attachments = [], // This is the specific attachment for only this prompt
+ }) {
+ const prompt = {
+ role: "system",
+ content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
+ };
+ return [
+ prompt,
+ ...formatChatHistory(chatHistory, this.#generateContent),
+ {
+ role: "user",
+ content: this.#generateContent({ userPrompt, attachments }),
+ },
+ ];
+ }
+
+ async getChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.isValidChatCompletionModel(this.model))
+ throw new Error(
+ `xAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const result = await LLMPerformanceMonitor.measureAsyncFunction(
+ this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ })
+ );
+
+ if (
+ !result.output.hasOwnProperty("choices") ||
+ result.output.choices.length === 0
+ )
+ return null;
+
+ return {
+ textResponse: result.output.choices[0].message.content,
+ metrics: {
+ prompt_tokens: result.output.usage.prompt_tokens || 0,
+ completion_tokens: result.output.usage.completion_tokens || 0,
+ total_tokens: result.output.usage.total_tokens || 0,
+ outputTps: result.output.usage.completion_tokens / result.duration,
+ duration: result.duration,
+ },
+ };
+ }
+
+ async streamGetChatCompletion(messages = null, { temperature = 0.7 }) {
+ if (!this.isValidChatCompletionModel(this.model))
+ throw new Error(
+ `xAI chat: ${this.model} is not valid for chat completion!`
+ );
+
+ const measuredStreamRequest = await LLMPerformanceMonitor.measureStream(
+ this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ }),
+ messages,
+ false
+ );
+
+ return measuredStreamRequest;
+ }
+
+ handleStream(response, stream, responseProps) {
+ return handleDefaultStreamResponseV2(response, stream, responseProps);
+ }
+
+ // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations
+ async embedTextInput(textInput) {
+ return await this.embedder.embedTextInput(textInput);
+ }
+ async embedChunks(textChunks = []) {
+ return await this.embedder.embedChunks(textChunks);
+ }
+
+ async compressMessages(promptArgs = {}, rawHistory = []) {
+ const { messageArrayCompressor } = require("../../helpers/chat");
+ const messageArray = this.constructPrompt(promptArgs);
+ return await messageArrayCompressor(this, messageArray, rawHistory);
+ }
+}
+
+module.exports = {
+ XAiLLM,
+};
diff --git a/server/utils/BackgroundWorkers/index.js b/server/utils/BackgroundWorkers/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..6fa43d0f596c5980efc1ea2023deef2f9e221599
--- /dev/null
+++ b/server/utils/BackgroundWorkers/index.js
@@ -0,0 +1,96 @@
+const path = require("path");
+const Graceful = require("@ladjs/graceful");
+const Bree = require("@mintplex-labs/bree");
+const setLogger = require("../logger");
+
+class BackgroundService {
+ name = "BackgroundWorkerService";
+ static _instance = null;
+ documentSyncEnabled = false;
+ #root = path.resolve(__dirname, "../../jobs");
+
+ #alwaysRunJobs = [
+ {
+ name: "cleanup-orphan-documents",
+ timeout: "1m",
+ interval: "12hr",
+ },
+ ];
+
+ #documentSyncJobs = [
+ // Job for auto-sync of documents
+ // https://github.com/breejs/bree
+ {
+ name: "sync-watched-documents",
+ interval: "1hr",
+ },
+ ];
+
+ constructor() {
+ if (BackgroundService._instance) {
+ this.#log("SINGLETON LOCK: Using existing BackgroundService.");
+ return BackgroundService._instance;
+ }
+
+ this.logger = setLogger();
+ BackgroundService._instance = this;
+ }
+
+ #log(text, ...args) {
+ console.log(`\x1b[36m[${this.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ async boot() {
+ const { DocumentSyncQueue } = require("../../models/documentSyncQueue");
+ this.documentSyncEnabled = await DocumentSyncQueue.enabled();
+ const jobsToRun = this.jobs();
+
+ this.#log("Starting...");
+ this.bree = new Bree({
+ logger: this.logger,
+ root: this.#root,
+ jobs: jobsToRun,
+ errorHandler: this.onError,
+ workerMessageHandler: this.onWorkerMessageHandler,
+ runJobsAs: "process",
+ });
+ this.graceful = new Graceful({ brees: [this.bree], logger: this.logger });
+ this.graceful.listen();
+ this.bree.start();
+ this.#log(
+ `Service started with ${jobsToRun.length} jobs`,
+ jobsToRun.map((j) => j.name)
+ );
+ }
+
+ async stop() {
+ this.#log("Stopping...");
+ if (!!this.graceful && !!this.bree) this.graceful.stopBree(this.bree, 0);
+ this.bree = null;
+ this.graceful = null;
+ this.#log("Service stopped");
+ }
+
+ /** @returns {import("@mintplex-labs/bree").Job[]} */
+ jobs() {
+ const activeJobs = [...this.#alwaysRunJobs];
+ if (this.documentSyncEnabled) activeJobs.push(...this.#documentSyncJobs);
+ return activeJobs;
+ }
+
+ onError(error, _workerMetadata) {
+ this.logger.error(`${error.message}`, {
+ service: "bg-worker",
+ origin: error.name,
+ });
+ }
+
+ onWorkerMessageHandler(message, _workerMetadata) {
+ this.logger.info(`${message.message}`, {
+ service: "bg-worker",
+ origin: message.name,
+ });
+ }
+}
+
+module.exports.BackgroundService = BackgroundService;
diff --git a/server/utils/DocumentManager/index.js b/server/utils/DocumentManager/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..17fd9860ee2b2acd86b80c9cc07a969788fee8aa
--- /dev/null
+++ b/server/utils/DocumentManager/index.js
@@ -0,0 +1,72 @@
+const fs = require("fs");
+const path = require("path");
+
+const documentsPath =
+ process.env.NODE_ENV === "development"
+ ? path.resolve(__dirname, `../../storage/documents`)
+ : path.resolve(process.env.STORAGE_DIR, `documents`);
+
+class DocumentManager {
+ constructor({ workspace = null, maxTokens = null }) {
+ this.workspace = workspace;
+ this.maxTokens = maxTokens || Number.POSITIVE_INFINITY;
+ this.documentStoragePath = documentsPath;
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[DocumentManager]\x1b[0m ${text}`, ...args);
+ }
+
+ async pinnedDocuments() {
+ if (!this.workspace) return [];
+ const { Document } = require("../../models/documents");
+ return await Document.where({
+ workspaceId: Number(this.workspace.id),
+ pinned: true,
+ });
+ }
+
+ async pinnedDocs() {
+ if (!this.workspace) return [];
+ const docPaths = (await this.pinnedDocuments()).map((doc) => doc.docpath);
+ if (docPaths.length === 0) return [];
+
+ let tokens = 0;
+ const pinnedDocs = [];
+ for await (const docPath of docPaths) {
+ try {
+ const filePath = path.resolve(this.documentStoragePath, docPath);
+ const data = JSON.parse(
+ fs.readFileSync(filePath, { encoding: "utf-8" })
+ );
+
+ if (
+ !data.hasOwnProperty("pageContent") ||
+ !data.hasOwnProperty("token_count_estimate")
+ ) {
+ this.log(
+ `Skipping document - Could not find page content or token_count_estimate in pinned source.`
+ );
+ continue;
+ }
+
+ if (tokens >= this.maxTokens) {
+ this.log(
+ `Skipping document - Token limit of ${this.maxTokens} has already been exceeded by pinned documents.`
+ );
+ continue;
+ }
+
+ pinnedDocs.push(data);
+ tokens += data.token_count_estimate || 0;
+ } catch {}
+ }
+
+ this.log(
+ `Found ${pinnedDocs.length} pinned sources - prepending to content with ~${tokens} tokens of content.`
+ );
+ return pinnedDocs;
+ }
+}
+
+module.exports.DocumentManager = DocumentManager;
diff --git a/server/utils/EmbeddingEngines/azureOpenAi/index.js b/server/utils/EmbeddingEngines/azureOpenAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..57907f45fd08f8db843fedf3b3362d55918ccefa
--- /dev/null
+++ b/server/utils/EmbeddingEngines/azureOpenAi/index.js
@@ -0,0 +1,109 @@
+const { toChunks } = require("../../helpers");
+
+class AzureOpenAiEmbedder {
+ constructor() {
+ const { AzureOpenAI } = require("openai");
+ if (!process.env.AZURE_OPENAI_ENDPOINT)
+ throw new Error("No Azure API endpoint was set.");
+ if (!process.env.AZURE_OPENAI_KEY)
+ throw new Error("No Azure API key was set.");
+
+ this.apiVersion = "2024-12-01-preview";
+ const openai = new AzureOpenAI({
+ apiKey: process.env.AZURE_OPENAI_KEY,
+ endpoint: process.env.AZURE_OPENAI_ENDPOINT,
+ apiVersion: this.apiVersion,
+ });
+
+ // We cannot assume the model fallback since the model is based on the deployment name
+ // and not the model name - so this will throw on embedding if the model is not defined.
+ this.model = process.env.EMBEDDING_MODEL_PREF;
+ this.openai = openai;
+
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ // https://learn.microsoft.com/en-us/azure/ai-services/openai/faq#i-am-trying-to-use-embeddings-and-received-the-error--invalidrequesterror--too-many-inputs--the-max-number-of-inputs-is-1---how-do-i-fix-this-:~:text=consisting%20of%20up%20to%2016%20inputs%20per%20API%20request
+ this.maxConcurrentChunks = 16;
+
+ // https://learn.microsoft.com/en-us/answers/questions/1188074/text-embedding-ada-002-token-context-length
+ this.embeddingMaxChunkLength = 2048;
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[AzureOpenAiEmbedder]\x1b[0m ${text}`, ...args);
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ if (!this.model) throw new Error("No Embedding Model preference defined.");
+
+ this.log(`Embedding ${textChunks.length} chunks...`);
+ // Because there is a limit on how many chunks can be sent at once to Azure OpenAI
+ // we concurrently execute each max batch of text chunks possible.
+ // Refer to constructor maxConcurrentChunks for more info.
+ const embeddingRequests = [];
+ for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) {
+ embeddingRequests.push(
+ new Promise((resolve) => {
+ this.openai.embeddings
+ .create({
+ model: this.model,
+ input: chunk,
+ })
+ .then((res) => {
+ resolve({ data: res.data, error: null });
+ })
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ resolve({ data: [], error: e });
+ });
+ })
+ );
+ }
+
+ const { data = [], error = null } = await Promise.all(
+ embeddingRequests
+ ).then((results) => {
+ // If any errors were returned from Azure abort the entire sequence because the embeddings
+ // will be incomplete.
+ const errors = results
+ .filter((res) => !!res.error)
+ .map((res) => res.error)
+ .flat();
+ if (errors.length > 0) {
+ let uniqueErrors = new Set();
+ errors.map((error) =>
+ uniqueErrors.add(`[${error.type}]: ${error.message}`)
+ );
+
+ return {
+ data: [],
+ error: Array.from(uniqueErrors).join(", "),
+ };
+ }
+ return {
+ data: results.map((res) => res?.data || []).flat(),
+ error: null,
+ };
+ });
+
+ if (!!error) throw new Error(`Azure OpenAI Failed to embed: ${error}`);
+ return data.length > 0 &&
+ data.every((embd) => embd.hasOwnProperty("embedding"))
+ ? data.map((embd) => embd.embedding)
+ : null;
+ }
+}
+
+module.exports = {
+ AzureOpenAiEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/cohere/index.js b/server/utils/EmbeddingEngines/cohere/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..0dfb61d0dea0ecd700c4950f7437210f8bef0ac1
--- /dev/null
+++ b/server/utils/EmbeddingEngines/cohere/index.js
@@ -0,0 +1,86 @@
+const { toChunks } = require("../../helpers");
+
+class CohereEmbedder {
+ constructor() {
+ if (!process.env.COHERE_API_KEY)
+ throw new Error("No Cohere API key was set.");
+
+ const { CohereClient } = require("cohere-ai");
+ const cohere = new CohereClient({
+ token: process.env.COHERE_API_KEY,
+ });
+
+ this.cohere = cohere;
+ this.model = process.env.EMBEDDING_MODEL_PREF || "embed-english-v3.0";
+ this.inputType = "search_document";
+
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ this.maxConcurrentChunks = 96; // Cohere's limit per request is 96
+ this.embeddingMaxChunkLength = 1945; // https://docs.cohere.com/docs/embed-2 - assume a token is roughly 4 letters with some padding
+ }
+
+ async embedTextInput(textInput) {
+ this.inputType = "search_query";
+ const result = await this.embedChunks([textInput]);
+ return result?.[0] || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ const embeddingRequests = [];
+ this.inputType = "search_document";
+
+ for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) {
+ embeddingRequests.push(
+ new Promise((resolve) => {
+ this.cohere
+ .embed({
+ texts: chunk,
+ model: this.model,
+ inputType: this.inputType,
+ })
+ .then((res) => {
+ resolve({ data: res.embeddings, error: null });
+ })
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ resolve({ data: [], error: e });
+ });
+ })
+ );
+ }
+
+ const { data = [], error = null } = await Promise.all(
+ embeddingRequests
+ ).then((results) => {
+ const errors = results
+ .filter((res) => !!res.error)
+ .map((res) => res.error)
+ .flat();
+
+ if (errors.length > 0) {
+ let uniqueErrors = new Set();
+ errors.map((error) =>
+ uniqueErrors.add(`[${error.type}]: ${error.message}`)
+ );
+ return { data: [], error: Array.from(uniqueErrors).join(", ") };
+ }
+
+ return {
+ data: results.map((res) => res?.data || []).flat(),
+ error: null,
+ };
+ });
+
+ if (!!error) throw new Error(`Cohere Failed to embed: ${error}`);
+
+ return data.length > 0 ? data : null;
+ }
+}
+
+module.exports = {
+ CohereEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/gemini/index.js b/server/utils/EmbeddingEngines/gemini/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..8c5054667283d71d631488ca57c4017895e06266
--- /dev/null
+++ b/server/utils/EmbeddingEngines/gemini/index.js
@@ -0,0 +1,118 @@
+const { toChunks } = require("../../helpers");
+
+const MODEL_MAP = {
+ "embedding-001": 2048,
+ "text-embedding-004": 2048,
+ "gemini-embedding-exp-03-07": 8192,
+};
+
+class GeminiEmbedder {
+ constructor() {
+ if (!process.env.GEMINI_EMBEDDING_API_KEY)
+ throw new Error("No Gemini API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.model = process.env.EMBEDDING_MODEL_PREF || "text-embedding-004";
+ this.openai = new OpenAIApi({
+ apiKey: process.env.GEMINI_EMBEDDING_API_KEY,
+ // Even models that are v1 in gemini API can be used with v1beta/openai/ endpoint and nobody knows why.
+ baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
+ });
+
+ this.maxConcurrentChunks = 4;
+
+ // https://ai.google.dev/gemini-api/docs/models/gemini#text-embedding-and-embedding
+ this.embeddingMaxChunkLength = MODEL_MAP[this.model] || 2_048;
+ this.log(
+ `Initialized with ${this.model} - Max Size: ${this.embeddingMaxChunkLength}`
+ );
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[GeminiEmbedder]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Embeds a single text input
+ * @param {string|string[]} textInput - The text to embed
+ * @returns {Promise>} The embedding values
+ */
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ /**
+ * Embeds a list of text inputs
+ * @param {string[]} textChunks - The list of text to embed
+ * @returns {Promise>>} The embedding values
+ */
+ async embedChunks(textChunks = []) {
+ this.log(`Embedding ${textChunks.length} chunks...`);
+
+ // Because there is a hard POST limit on how many chunks can be sent at once to OpenAI (~8mb)
+ // we concurrently execute each max batch of text chunks possible.
+ // Refer to constructor maxConcurrentChunks for more info.
+ const embeddingRequests = [];
+ for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) {
+ embeddingRequests.push(
+ new Promise((resolve) => {
+ this.openai.embeddings
+ .create({
+ model: this.model,
+ input: chunk,
+ })
+ .then((result) => {
+ resolve({ data: result?.data, error: null });
+ })
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ resolve({ data: [], error: e });
+ });
+ })
+ );
+ }
+
+ const { data = [], error = null } = await Promise.all(
+ embeddingRequests
+ ).then((results) => {
+ // If any errors were returned from OpenAI abort the entire sequence because the embeddings
+ // will be incomplete.
+ const errors = results
+ .filter((res) => !!res.error)
+ .map((res) => res.error)
+ .flat();
+ if (errors.length > 0) {
+ let uniqueErrors = new Set();
+ errors.map((error) =>
+ uniqueErrors.add(`[${error.type}]: ${error.message}`)
+ );
+
+ return {
+ data: [],
+ error: Array.from(uniqueErrors).join(", "),
+ };
+ }
+ return {
+ data: results.map((res) => res?.data || []).flat(),
+ error: null,
+ };
+ });
+
+ if (!!error) throw new Error(`Gemini Failed to embed: ${error}`);
+ return data.length > 0 &&
+ data.every((embd) => embd.hasOwnProperty("embedding"))
+ ? data.map((embd) => embd.embedding)
+ : null;
+ }
+}
+
+module.exports = {
+ GeminiEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/genericOpenAi/index.js b/server/utils/EmbeddingEngines/genericOpenAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..a8a3ac1a584199db0bfc8ce5c1794e6aaf8c5f13
--- /dev/null
+++ b/server/utils/EmbeddingEngines/genericOpenAi/index.js
@@ -0,0 +1,122 @@
+const { toChunks, maximumChunkLength } = require("../../helpers");
+
+class GenericOpenAiEmbedder {
+ constructor() {
+ if (!process.env.EMBEDDING_BASE_PATH)
+ throw new Error(
+ "GenericOpenAI must have a valid base path to use for the api."
+ );
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.basePath = process.env.EMBEDDING_BASE_PATH;
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.GENERIC_OPEN_AI_EMBEDDING_API_KEY ?? null,
+ });
+ this.model = process.env.EMBEDDING_MODEL_PREF ?? null;
+ this.embeddingMaxChunkLength = maximumChunkLength();
+
+ // this.maxConcurrentChunks is delegated to the getter below.
+ // Refer to your specific model and provider you use this class with to determine a valid maxChunkLength
+ this.log(`Initialized ${this.model}`, {
+ baseURL: this.basePath,
+ maxConcurrentChunks: this.maxConcurrentChunks,
+ embeddingMaxChunkLength: this.embeddingMaxChunkLength,
+ });
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[GenericOpenAiEmbedder]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * returns the `GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS` env variable as a number or null if the env variable is not set or is not a number.
+ * The minimum delay is 500ms.
+ *
+ * For some implementation this is necessary to avoid 429 errors due to rate limiting or
+ * hardware limitations where a single-threaded process is not able to handle the requests fast enough.
+ * @returns {number}
+ */
+ get apiRequestDelay() {
+ if (!("GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS" in process.env)) return null;
+ if (isNaN(Number(process.env.GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS)))
+ return null;
+ const delayTimeout = Number(
+ process.env.GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS
+ );
+ if (delayTimeout < 500) return 500; // minimum delay of 500ms
+ return delayTimeout;
+ }
+
+ /**
+ * runs the delay if it is set and valid.
+ * @returns {Promise}
+ */
+ async runDelay() {
+ if (!this.apiRequestDelay) return;
+ this.log(`Delaying new batch request for ${this.apiRequestDelay}ms`);
+ await new Promise((resolve) => setTimeout(resolve, this.apiRequestDelay));
+ }
+
+ /**
+ * returns the `GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS` env variable as a number
+ * or 500 if the env variable is not set or is not a number.
+ * @returns {number}
+ */
+ get maxConcurrentChunks() {
+ if (!process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS)
+ return 500;
+ if (
+ isNaN(Number(process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS))
+ )
+ return 500;
+ return Number(process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS);
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ // Because there is a hard POST limit on how many chunks can be sent at once to OpenAI (~8mb)
+ // we sequentially execute each max batch of text chunks possible.
+ // Refer to constructor maxConcurrentChunks for more info.
+ const allResults = [];
+ for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) {
+ const { data = [], error = null } = await new Promise((resolve) => {
+ this.openai.embeddings
+ .create({
+ model: this.model,
+ input: chunk,
+ })
+ .then((result) => resolve({ data: result?.data, error: null }))
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ resolve({ data: [], error: e });
+ });
+ });
+
+ // If any errors were returned from OpenAI abort the entire sequence because the embeddings
+ // will be incomplete.
+ if (error)
+ throw new Error(`GenericOpenAI Failed to embed: ${error.message}`);
+ allResults.push(...(data || []));
+ if (this.apiRequestDelay) await this.runDelay();
+ }
+
+ return allResults.length > 0 &&
+ allResults.every((embd) => embd.hasOwnProperty("embedding"))
+ ? allResults.map((embd) => embd.embedding)
+ : null;
+ }
+}
+
+module.exports = {
+ GenericOpenAiEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/liteLLM/index.js b/server/utils/EmbeddingEngines/liteLLM/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd22480b1c6517517fbceaa9150730c0812c3ac7
--- /dev/null
+++ b/server/utils/EmbeddingEngines/liteLLM/index.js
@@ -0,0 +1,93 @@
+const { toChunks, maximumChunkLength } = require("../../helpers");
+
+class LiteLLMEmbedder {
+ constructor() {
+ const { OpenAI: OpenAIApi } = require("openai");
+ if (!process.env.LITE_LLM_BASE_PATH)
+ throw new Error(
+ "LiteLLM must have a valid base path to use for the api."
+ );
+ this.basePath = process.env.LITE_LLM_BASE_PATH;
+ this.openai = new OpenAIApi({
+ baseURL: this.basePath,
+ apiKey: process.env.LITE_LLM_API_KEY ?? null,
+ });
+ this.model = process.env.EMBEDDING_MODEL_PREF || "text-embedding-ada-002";
+
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ this.maxConcurrentChunks = 500;
+ this.embeddingMaxChunkLength = maximumChunkLength();
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ // Because there is a hard POST limit on how many chunks can be sent at once to LiteLLM (~8mb)
+ // we concurrently execute each max batch of text chunks possible.
+ // Refer to constructor maxConcurrentChunks for more info.
+ const embeddingRequests = [];
+ for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) {
+ embeddingRequests.push(
+ new Promise((resolve) => {
+ this.openai.embeddings
+ .create({
+ model: this.model,
+ input: chunk,
+ })
+ .then((result) => {
+ resolve({ data: result?.data, error: null });
+ })
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ resolve({ data: [], error: e });
+ });
+ })
+ );
+ }
+
+ const { data = [], error = null } = await Promise.all(
+ embeddingRequests
+ ).then((results) => {
+ // If any errors were returned from LiteLLM abort the entire sequence because the embeddings
+ // will be incomplete.
+ const errors = results
+ .filter((res) => !!res.error)
+ .map((res) => res.error)
+ .flat();
+ if (errors.length > 0) {
+ let uniqueErrors = new Set();
+ errors.map((error) =>
+ uniqueErrors.add(`[${error.type}]: ${error.message}`)
+ );
+
+ return {
+ data: [],
+ error: Array.from(uniqueErrors).join(", "),
+ };
+ }
+ return {
+ data: results.map((res) => res?.data || []).flat(),
+ error: null,
+ };
+ });
+
+ if (!!error) throw new Error(`LiteLLM Failed to embed: ${error}`);
+ return data.length > 0 &&
+ data.every((embd) => embd.hasOwnProperty("embedding"))
+ ? data.map((embd) => embd.embedding)
+ : null;
+ }
+}
+
+module.exports = {
+ LiteLLMEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/lmstudio/index.js b/server/utils/EmbeddingEngines/lmstudio/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e94f45aaba073a010890c726be81f350a2996b4b
--- /dev/null
+++ b/server/utils/EmbeddingEngines/lmstudio/index.js
@@ -0,0 +1,117 @@
+const { parseLMStudioBasePath } = require("../../AiProviders/lmStudio");
+const { maximumChunkLength } = require("../../helpers");
+
+class LMStudioEmbedder {
+ constructor() {
+ if (!process.env.EMBEDDING_BASE_PATH)
+ throw new Error("No embedding base path was set.");
+ if (!process.env.EMBEDDING_MODEL_PREF)
+ throw new Error("No embedding model was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.lmstudio = new OpenAIApi({
+ baseURL: parseLMStudioBasePath(process.env.EMBEDDING_BASE_PATH),
+ apiKey: null,
+ });
+ this.model = process.env.EMBEDDING_MODEL_PREF;
+
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ this.maxConcurrentChunks = 1;
+ this.embeddingMaxChunkLength = maximumChunkLength();
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ async #isAlive() {
+ return await this.lmstudio.models
+ .list()
+ .then((res) => res?.data?.length > 0)
+ .catch((e) => {
+ this.log(e.message);
+ return false;
+ });
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ if (!(await this.#isAlive()))
+ throw new Error(
+ `LMStudio service could not be reached. Is LMStudio running?`
+ );
+
+ this.log(
+ `Embedding ${textChunks.length} chunks of text with ${this.model}.`
+ );
+
+ // LMStudio will drop all queued requests now? So if there are many going on
+ // we need to do them sequentially or else only the first resolves and the others
+ // get dropped or go unanswered >:(
+ let results = [];
+ let hasError = false;
+ for (const chunk of textChunks) {
+ if (hasError) break; // If an error occurred don't continue and exit early.
+ results.push(
+ await this.lmstudio.embeddings
+ .create({
+ model: this.model,
+ input: chunk,
+ encoding_format: "base64",
+ })
+ .then((result) => {
+ const embedding = result.data?.[0]?.embedding;
+ if (!Array.isArray(embedding) || !embedding.length)
+ throw {
+ type: "EMPTY_ARR",
+ message: "The embedding was empty from LMStudio",
+ };
+ console.log(`Embedding length: ${embedding.length}`);
+ return { data: embedding, error: null };
+ })
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ hasError = true;
+ return { data: [], error: e };
+ })
+ );
+ }
+
+ // Accumulate errors from embedding.
+ // If any are present throw an abort error.
+ const errors = results
+ .filter((res) => !!res.error)
+ .map((res) => res.error)
+ .flat();
+
+ if (errors.length > 0) {
+ let uniqueErrors = new Set();
+ console.log(errors);
+ errors.map((error) =>
+ uniqueErrors.add(`[${error.type}]: ${error.message}`)
+ );
+
+ if (errors.length > 0)
+ throw new Error(
+ `LMStudio Failed to embed: ${Array.from(uniqueErrors).join(", ")}`
+ );
+ }
+
+ const data = results.map((res) => res?.data || []);
+ return data.length > 0 ? data : null;
+ }
+}
+
+module.exports = {
+ LMStudioEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/localAi/index.js b/server/utils/EmbeddingEngines/localAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..363ad182e85e86dda383b8969a4929dda6157bd6
--- /dev/null
+++ b/server/utils/EmbeddingEngines/localAi/index.js
@@ -0,0 +1,89 @@
+const { toChunks, maximumChunkLength } = require("../../helpers");
+
+class LocalAiEmbedder {
+ constructor() {
+ if (!process.env.EMBEDDING_BASE_PATH)
+ throw new Error("No embedding base path was set.");
+ if (!process.env.EMBEDDING_MODEL_PREF)
+ throw new Error("No embedding model was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ baseURL: process.env.EMBEDDING_BASE_PATH,
+ apiKey: process.env.LOCAL_AI_API_KEY ?? null,
+ });
+
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ this.maxConcurrentChunks = 50;
+ this.embeddingMaxChunkLength = maximumChunkLength();
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ const embeddingRequests = [];
+ for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) {
+ embeddingRequests.push(
+ new Promise((resolve) => {
+ this.openai.embeddings
+ .create({
+ model: process.env.EMBEDDING_MODEL_PREF,
+ input: chunk,
+ })
+ .then((result) => {
+ resolve({ data: result?.data, error: null });
+ })
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ resolve({ data: [], error: e });
+ });
+ })
+ );
+ }
+
+ const { data = [], error = null } = await Promise.all(
+ embeddingRequests
+ ).then((results) => {
+ // If any errors were returned from LocalAI abort the entire sequence because the embeddings
+ // will be incomplete.
+ const errors = results
+ .filter((res) => !!res.error)
+ .map((res) => res.error)
+ .flat();
+ if (errors.length > 0) {
+ let uniqueErrors = new Set();
+ errors.map((error) =>
+ uniqueErrors.add(`[${error.type}]: ${error.message}`)
+ );
+
+ return {
+ data: [],
+ error: Array.from(uniqueErrors).join(", "),
+ };
+ }
+ return {
+ data: results.map((res) => res?.data || []).flat(),
+ error: null,
+ };
+ });
+
+ if (!!error) throw new Error(`LocalAI Failed to embed: ${error}`);
+ return data.length > 0 &&
+ data.every((embd) => embd.hasOwnProperty("embedding"))
+ ? data.map((embd) => embd.embedding)
+ : null;
+ }
+}
+
+module.exports = {
+ LocalAiEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/mistral/index.js b/server/utils/EmbeddingEngines/mistral/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..23f4ddc8a99e6b152d261d4a57b0757f9dc9fc3d
--- /dev/null
+++ b/server/utils/EmbeddingEngines/mistral/index.js
@@ -0,0 +1,43 @@
+class MistralEmbedder {
+ constructor() {
+ if (!process.env.MISTRAL_API_KEY)
+ throw new Error("No Mistral API key was set.");
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ baseURL: "https://api.mistral.ai/v1",
+ apiKey: process.env.MISTRAL_API_KEY ?? null,
+ });
+ this.model = process.env.EMBEDDING_MODEL_PREF || "mistral-embed";
+ }
+
+ async embedTextInput(textInput) {
+ try {
+ const response = await this.openai.embeddings.create({
+ model: this.model,
+ input: textInput,
+ });
+ return response?.data[0]?.embedding || [];
+ } catch (error) {
+ console.error("Failed to get embedding from Mistral.", error.message);
+ return [];
+ }
+ }
+
+ async embedChunks(textChunks = []) {
+ try {
+ const response = await this.openai.embeddings.create({
+ model: this.model,
+ input: textChunks,
+ });
+ return response?.data?.map((emb) => emb.embedding) || [];
+ } catch (error) {
+ console.error("Failed to get embeddings from Mistral.", error.message);
+ return new Array(textChunks.length).fill([]);
+ }
+ }
+}
+
+module.exports = {
+ MistralEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/native/constants.js b/server/utils/EmbeddingEngines/native/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..76c4d96c3ce7cf5f98c0355afc541dea9312262c
--- /dev/null
+++ b/server/utils/EmbeddingEngines/native/constants.js
@@ -0,0 +1,63 @@
+const SUPPORTED_NATIVE_EMBEDDING_MODELS = {
+ "Xenova/all-MiniLM-L6-v2": {
+ maxConcurrentChunks: 25,
+ // Right now, this is NOT the token length, and is instead the number of characters
+ // that can be processed in a single pass. So we override to 1,000 characters.
+ // roughtly the max number of tokens assuming 2 characters per token. (undershooting)
+ // embeddingMaxChunkLength: 512, (from the model card)
+ embeddingMaxChunkLength: 1_000,
+ chunkPrefix: "",
+ queryPrefix: "",
+ apiInfo: {
+ id: "Xenova/all-MiniLM-L6-v2",
+ name: "all-MiniLM-L6-v2",
+ description:
+ "A lightweight and fast model for embedding text. The default model for AnythingLLM.",
+ lang: "English",
+ size: "23MB",
+ modelCard: "https://huggingface.co/Xenova/all-MiniLM-L6-v2",
+ },
+ },
+ "Xenova/nomic-embed-text-v1": {
+ maxConcurrentChunks: 5,
+ // Right now, this is NOT the token length, and is instead the number of characters
+ // that can be processed in a single pass. So we override to 16,000 characters.
+ // roughtly the max number of tokens assuming 2 characters per token. (undershooting)
+ // embeddingMaxChunkLength: 8192, (from the model card)
+ embeddingMaxChunkLength: 16_000,
+ chunkPrefix: "search_document: ",
+ queryPrefix: "search_query: ",
+ apiInfo: {
+ id: "Xenova/nomic-embed-text-v1",
+ name: "nomic-embed-text-v1",
+ description:
+ "A high-performing open embedding model with a large token context window. Requires more processing power and memory.",
+ lang: "English",
+ size: "139MB",
+ modelCard: "https://huggingface.co/Xenova/nomic-embed-text-v1",
+ },
+ },
+ "MintplexLabs/multilingual-e5-small": {
+ maxConcurrentChunks: 5,
+ // Right now, this is NOT the token length, and is instead the number of characters
+ // that can be processed in a single pass. So we override to 1,000 characters.
+ // roughtly the max number of tokens assuming 2 characters per token. (undershooting)
+ // embeddingMaxChunkLength: 512, (from the model card)
+ embeddingMaxChunkLength: 1_000,
+ chunkPrefix: "passage: ",
+ queryPrefix: "query: ",
+ apiInfo: {
+ id: "MintplexLabs/multilingual-e5-small",
+ name: "multilingual-e5-small",
+ description:
+ "A larger multilingual embedding model that supports 100+ languages. Requires more processing power and memory.",
+ lang: "100+ languages",
+ size: "487MB",
+ modelCard: "https://huggingface.co/intfloat/multilingual-e5-small",
+ },
+ },
+};
+
+module.exports = {
+ SUPPORTED_NATIVE_EMBEDDING_MODELS,
+};
diff --git a/server/utils/EmbeddingEngines/native/index.js b/server/utils/EmbeddingEngines/native/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..9142d3b3a9c5a6d508065407628831b90c346d84
--- /dev/null
+++ b/server/utils/EmbeddingEngines/native/index.js
@@ -0,0 +1,283 @@
+const path = require("path");
+const fs = require("fs");
+const { toChunks } = require("../../helpers");
+const { v4 } = require("uuid");
+const { SUPPORTED_NATIVE_EMBEDDING_MODELS } = require("./constants");
+
+class NativeEmbedder {
+ static defaultModel = "Xenova/all-MiniLM-L6-v2";
+
+ /**
+ * Supported embedding models for native.
+ * @type {Record}
+ */
+ static supportedModels = SUPPORTED_NATIVE_EMBEDDING_MODELS;
+
+ // This is a folder that Mintplex Labs hosts for those who cannot capture the HF model download
+ // endpoint for various reasons. This endpoint is not guaranteed to be active or maintained
+ // and may go offline at any time at Mintplex Labs's discretion.
+ #fallbackHost = "https://cdn.anythingllm.com/support/models/";
+
+ constructor() {
+ this.model = this.getEmbeddingModel();
+ this.modelInfo = this.getEmbedderInfo();
+ this.cacheDir = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, `models`)
+ : path.resolve(__dirname, `../../../storage/models`)
+ );
+ this.modelPath = path.resolve(this.cacheDir, ...this.model.split("/"));
+ this.modelDownloaded = fs.existsSync(this.modelPath);
+
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ this.maxConcurrentChunks = this.modelInfo.maxConcurrentChunks;
+ this.embeddingMaxChunkLength = this.modelInfo.embeddingMaxChunkLength;
+
+ // Make directory when it does not exist in existing installations
+ if (!fs.existsSync(this.cacheDir)) fs.mkdirSync(this.cacheDir);
+ this.log(`Initialized ${this.model}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[NativeEmbedder]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Get the selected model from the environment variable.
+ * @returns {string}
+ */
+ static _getEmbeddingModel() {
+ const envModel =
+ process.env.EMBEDDING_MODEL_PREF ?? NativeEmbedder.defaultModel;
+ if (NativeEmbedder.supportedModels?.[envModel]) return envModel;
+ return NativeEmbedder.defaultModel;
+ }
+
+ get embeddingPrefix() {
+ return NativeEmbedder.supportedModels[this.model]?.chunkPrefix || "";
+ }
+
+ get queryPrefix() {
+ return NativeEmbedder.supportedModels[this.model]?.queryPrefix || "";
+ }
+
+ /**
+ * Get the available models in an API response format
+ * we can use to populate the frontend dropdown.
+ * @returns {{id: string, name: string, description: string, lang: string, size: string, modelCard: string}[]}
+ */
+ static availableModels() {
+ return Object.values(NativeEmbedder.supportedModels).map(
+ (model) => model.apiInfo
+ );
+ }
+
+ /**
+ * Get the embedding model to use.
+ * We only support a few models and will default to the default model if the environment variable is not set or not supported.
+ *
+ * Why only a few? Because we need to mirror them on the CDN so non-US users can download them.
+ * eg: "Xenova/all-MiniLM-L6-v2"
+ * eg: "Xenova/nomic-embed-text-v1"
+ * @returns {string}
+ */
+ getEmbeddingModel() {
+ const envModel =
+ process.env.EMBEDDING_MODEL_PREF ?? NativeEmbedder.defaultModel;
+ if (NativeEmbedder.supportedModels?.[envModel]) return envModel;
+ return NativeEmbedder.defaultModel;
+ }
+
+ /**
+ * Get the embedding model info.
+ *
+ * Will always fallback to the default model if the model is not supported.
+ * @returns {Object}
+ */
+ getEmbedderInfo() {
+ const model = this.getEmbeddingModel();
+ return NativeEmbedder.supportedModels[model];
+ }
+
+ #tempfilePath() {
+ const filename = `${v4()}.tmp`;
+ const tmpPath = process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, "tmp")
+ : path.resolve(__dirname, `../../../storage/tmp`);
+ if (!fs.existsSync(tmpPath)) fs.mkdirSync(tmpPath, { recursive: true });
+ return path.resolve(tmpPath, filename);
+ }
+
+ async #writeToTempfile(filePath, data) {
+ try {
+ await fs.promises.appendFile(filePath, data, { encoding: "utf8" });
+ } catch (e) {
+ console.error(`Error writing to tempfile: ${e}`);
+ }
+ }
+
+ async #fetchWithHost(hostOverride = null) {
+ try {
+ // Convert ESM to CommonJS via import so we can load this library.
+ const pipeline = (...args) =>
+ import("@xenova/transformers").then(({ pipeline, env }) => {
+ if (!this.modelDownloaded) {
+ // if model is not downloaded, we will log where we are fetching from.
+ if (hostOverride) {
+ env.remoteHost = hostOverride;
+ env.remotePathTemplate = "{model}/"; // Our S3 fallback url does not support revision File structure.
+ }
+ this.log(`Downloading ${this.model} from ${env.remoteHost}`);
+ }
+ return pipeline(...args);
+ });
+ return {
+ pipeline: await pipeline("feature-extraction", this.model, {
+ cache_dir: this.cacheDir,
+ ...(!this.modelDownloaded
+ ? {
+ // Show download progress if we need to download any files
+ progress_callback: (data) => {
+ if (!data.hasOwnProperty("progress")) return;
+ console.log(
+ `\x1b[36m[NativeEmbedder - Downloading model]\x1b[0m ${
+ data.file
+ } ${~~data?.progress}%`
+ );
+ },
+ }
+ : {}),
+ }),
+ retry: false,
+ error: null,
+ };
+ } catch (error) {
+ return {
+ pipeline: null,
+ retry: hostOverride === null ? this.#fallbackHost : false,
+ error,
+ };
+ }
+ }
+
+ // This function will do a single fallback attempt (not recursive on purpose) to try to grab the embedder model on first embed
+ // since at time, some clients cannot properly download the model from HF servers due to a number of reasons (IP, VPN, etc).
+ // Given this model is critical and nobody reads the GitHub issues before submitting the bug, we get the same bug
+ // report 20 times a day: https://github.com/Mintplex-Labs/anything-llm/issues/821
+ // So to attempt to monkey-patch this we have a single fallback URL to help alleviate duplicate bug reports.
+ async embedderClient() {
+ if (!this.modelDownloaded)
+ this.log(
+ "The native embedding model has never been run and will be downloaded right now. Subsequent runs will be faster. (~23MB)"
+ );
+
+ let fetchResponse = await this.#fetchWithHost();
+ if (fetchResponse.pipeline !== null) {
+ this.modelDownloaded = true;
+ return fetchResponse.pipeline;
+ }
+
+ this.log(
+ `Failed to download model from primary URL. Using fallback ${fetchResponse.retry}`
+ );
+ if (!!fetchResponse.retry)
+ fetchResponse = await this.#fetchWithHost(fetchResponse.retry);
+ if (fetchResponse.pipeline !== null) {
+ this.modelDownloaded = true;
+ return fetchResponse.pipeline;
+ }
+
+ throw fetchResponse.error;
+ }
+
+ /**
+ * Apply the query prefix to the text input if it is required by the model.
+ * eg: nomic-embed-text-v1 requires a query prefix for embedding/searching.
+ * @param {string|string[]} textInput - The text to embed.
+ * @returns {string|string[]} The text with the prefix applied.
+ */
+ #applyQueryPrefix(textInput) {
+ if (!this.queryPrefix) return textInput;
+ if (Array.isArray(textInput))
+ textInput = textInput.map((text) => `${this.queryPrefix}${text}`);
+ else textInput = `${this.queryPrefix}${textInput}`;
+ return textInput;
+ }
+
+ /**
+ * Embed a single text input.
+ * @param {string|string[]} textInput - The text to embed.
+ * @returns {Promise>} The embedded text.
+ */
+ async embedTextInput(textInput) {
+ textInput = this.#applyQueryPrefix(textInput);
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ // If you are thinking you want to edit this function - you probably don't.
+ // This process was benchmarked heavily on a t3.small (2GB RAM 1vCPU)
+ // and without careful memory management for the V8 garbage collector
+ // this function will likely result in an OOM on any resource-constrained deployment.
+ // To help manage very large documents we run a concurrent write-log each iteration
+ // to keep the embedding result out of memory. The `maxConcurrentChunk` is set to 25,
+ // as 50 seems to overflow no matter what. Given the above, memory use hovers around ~30%
+ // during a very large document (>100K words) but can spike up to 70% before gc.
+ // This seems repeatable for all document sizes.
+ // While this does take a while, it is zero set up and is 100% free and on-instance.
+ // It still may crash depending on other elements at play - so no promises it works under all conditions.
+ async embedChunks(textChunks = []) {
+ const tmpFilePath = this.#tempfilePath();
+ const chunks = toChunks(textChunks, this.maxConcurrentChunks);
+ const chunkLen = chunks.length;
+
+ for (let [idx, chunk] of chunks.entries()) {
+ if (idx === 0) await this.#writeToTempfile(tmpFilePath, "[");
+ let data;
+ let pipeline = await this.embedderClient();
+ let output = await pipeline(chunk, {
+ pooling: "mean",
+ normalize: true,
+ });
+
+ if (output.length === 0) {
+ pipeline = null;
+ output = null;
+ data = null;
+ continue;
+ }
+
+ data = JSON.stringify(output.tolist());
+ await this.#writeToTempfile(tmpFilePath, data);
+ this.log(`Embedded Chunk Group ${idx + 1} of ${chunkLen}`);
+ if (chunkLen - 1 !== idx) await this.#writeToTempfile(tmpFilePath, ",");
+ if (chunkLen - 1 === idx) await this.#writeToTempfile(tmpFilePath, "]");
+ pipeline = null;
+ output = null;
+ data = null;
+ }
+
+ const embeddingResults = JSON.parse(
+ fs.readFileSync(tmpFilePath, { encoding: "utf-8" })
+ );
+ fs.rmSync(tmpFilePath, { force: true });
+ return embeddingResults.length > 0 ? embeddingResults.flat() : null;
+ }
+}
+
+module.exports = {
+ NativeEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/ollama/index.js b/server/utils/EmbeddingEngines/ollama/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..7e2f636e25942aea0b05c04d045bf9ee5e29fd46
--- /dev/null
+++ b/server/utils/EmbeddingEngines/ollama/index.js
@@ -0,0 +1,104 @@
+const { maximumChunkLength } = require("../../helpers");
+const { Ollama } = require("ollama");
+
+class OllamaEmbedder {
+ constructor() {
+ if (!process.env.EMBEDDING_BASE_PATH)
+ throw new Error("No embedding base path was set.");
+ if (!process.env.EMBEDDING_MODEL_PREF)
+ throw new Error("No embedding model was set.");
+
+ this.basePath = process.env.EMBEDDING_BASE_PATH;
+ this.model = process.env.EMBEDDING_MODEL_PREF;
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ this.maxConcurrentChunks = 1;
+ this.embeddingMaxChunkLength = maximumChunkLength();
+ this.client = new Ollama({ host: this.basePath });
+ this.log(
+ `initialized with model ${this.model} at ${this.basePath}. num_ctx: ${this.embeddingMaxChunkLength}`
+ );
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Checks if the Ollama service is alive by pinging the base path.
+ * @returns {Promise} - A promise that resolves to true if the service is alive, false otherwise.
+ */
+ async #isAlive() {
+ return await fetch(this.basePath)
+ .then((res) => res.ok)
+ .catch((e) => {
+ this.log(e.message);
+ return false;
+ });
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ /**
+ * This function takes an array of text chunks and embeds them using the Ollama API.
+ * chunks are processed sequentially to avoid overwhelming the API with too many requests
+ * or running out of resources on the endpoint running the ollama instance.
+ *
+ * We will use the num_ctx option to set the maximum context window to the max chunk length defined by the user in the settings
+ * so that the maximum context window is used and content is not truncated.
+ *
+ * We also assume the default keep alive option. This could cause issues with models being unloaded and reloaded
+ * on load memory machines, but that is simply a user-end issue we cannot control. If the LLM and embedder are
+ * constantly being loaded and unloaded, the user should use another LLM or Embedder to avoid this issue.
+ * @param {string[]} textChunks - An array of text chunks to embed.
+ * @returns {Promise>} - A promise that resolves to an array of embeddings.
+ */
+ async embedChunks(textChunks = []) {
+ if (!(await this.#isAlive()))
+ throw new Error(
+ `Ollama service could not be reached. Is Ollama running?`
+ );
+ this.log(
+ `Embedding ${textChunks.length} chunks of text with ${this.model}.`
+ );
+
+ let data = [];
+ let error = null;
+
+ for (const chunk of textChunks) {
+ try {
+ const res = await this.client.embeddings({
+ model: this.model,
+ prompt: chunk,
+ options: {
+ // Always set the num_ctx to the max chunk length defined by the user in the settings
+ // so that the maximum context window is used and content is not truncated.
+ num_ctx: this.embeddingMaxChunkLength,
+ },
+ });
+
+ const { embedding } = res;
+ if (!Array.isArray(embedding) || embedding.length === 0)
+ throw new Error("Ollama returned an empty embedding for chunk!");
+
+ data.push(embedding);
+ } catch (err) {
+ this.log(err.message);
+ error = err.message;
+ data = [];
+ break;
+ }
+ }
+
+ if (!!error) throw new Error(`Ollama Failed to embed: ${error}`);
+ return data.length > 0 ? data : null;
+ }
+}
+
+module.exports = {
+ OllamaEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/openAi/index.js b/server/utils/EmbeddingEngines/openAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..9976ef54d142f4d39b9b0e1ccb01e8765b649e77
--- /dev/null
+++ b/server/utils/EmbeddingEngines/openAi/index.js
@@ -0,0 +1,96 @@
+const { toChunks } = require("../../helpers");
+
+class OpenAiEmbedder {
+ constructor() {
+ if (!process.env.OPEN_AI_KEY) throw new Error("No OpenAI API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ apiKey: process.env.OPEN_AI_KEY,
+ });
+ this.model = process.env.EMBEDDING_MODEL_PREF || "text-embedding-ada-002";
+
+ // Limit of how many strings we can process in a single pass to stay with resource or network limits
+ this.maxConcurrentChunks = 500;
+
+ // https://platform.openai.com/docs/guides/embeddings/embedding-models
+ this.embeddingMaxChunkLength = 8_191;
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[OpenAiEmbedder]\x1b[0m ${text}`, ...args);
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.embedChunks(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+ return result?.[0] || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ this.log(`Embedding ${textChunks.length} chunks...`);
+
+ // Because there is a hard POST limit on how many chunks can be sent at once to OpenAI (~8mb)
+ // we concurrently execute each max batch of text chunks possible.
+ // Refer to constructor maxConcurrentChunks for more info.
+ const embeddingRequests = [];
+ for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) {
+ embeddingRequests.push(
+ new Promise((resolve) => {
+ this.openai.embeddings
+ .create({
+ model: this.model,
+ input: chunk,
+ })
+ .then((result) => {
+ resolve({ data: result?.data, error: null });
+ })
+ .catch((e) => {
+ e.type =
+ e?.response?.data?.error?.code ||
+ e?.response?.status ||
+ "failed_to_embed";
+ e.message = e?.response?.data?.error?.message || e.message;
+ resolve({ data: [], error: e });
+ });
+ })
+ );
+ }
+
+ const { data = [], error = null } = await Promise.all(
+ embeddingRequests
+ ).then((results) => {
+ // If any errors were returned from OpenAI abort the entire sequence because the embeddings
+ // will be incomplete.
+ const errors = results
+ .filter((res) => !!res.error)
+ .map((res) => res.error)
+ .flat();
+ if (errors.length > 0) {
+ let uniqueErrors = new Set();
+ errors.map((error) =>
+ uniqueErrors.add(`[${error.type}]: ${error.message}`)
+ );
+
+ return {
+ data: [],
+ error: Array.from(uniqueErrors).join(", "),
+ };
+ }
+ return {
+ data: results.map((res) => res?.data || []).flat(),
+ error: null,
+ };
+ });
+
+ if (!!error) throw new Error(`OpenAI Failed to embed: ${error}`);
+ return data.length > 0 &&
+ data.every((embd) => embd.hasOwnProperty("embedding"))
+ ? data.map((embd) => embd.embedding)
+ : null;
+ }
+}
+
+module.exports = {
+ OpenAiEmbedder,
+};
diff --git a/server/utils/EmbeddingEngines/voyageAi/index.js b/server/utils/EmbeddingEngines/voyageAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..9fda4f87c02a82534cfbff9cec760a7edcabe52f
--- /dev/null
+++ b/server/utils/EmbeddingEngines/voyageAi/index.js
@@ -0,0 +1,71 @@
+class VoyageAiEmbedder {
+ constructor() {
+ if (!process.env.VOYAGEAI_API_KEY)
+ throw new Error("No Voyage AI API key was set.");
+
+ const {
+ VoyageEmbeddings,
+ } = require("@langchain/community/embeddings/voyage");
+
+ this.model = process.env.EMBEDDING_MODEL_PREF || "voyage-3-lite";
+ this.voyage = new VoyageEmbeddings({
+ apiKey: process.env.VOYAGEAI_API_KEY,
+ modelName: this.model,
+ // Voyage AI's limit per request is 128 https://docs.voyageai.com/docs/rate-limits#use-larger-batches
+ batchSize: 128,
+ });
+ this.embeddingMaxChunkLength = this.#getMaxEmbeddingLength();
+ }
+
+ // https://docs.voyageai.com/docs/embeddings
+ #getMaxEmbeddingLength() {
+ switch (this.model) {
+ case "voyage-finance-2":
+ case "voyage-multilingual-2":
+ case "voyage-3":
+ case "voyage-3-lite":
+ case "voyage-3-large":
+ case "voyage-code-3":
+ return 32_000;
+ case "voyage-large-2-instruct":
+ case "voyage-law-2":
+ case "voyage-code-2":
+ case "voyage-large-2":
+ return 16_000;
+ case "voyage-2":
+ return 4_000;
+ default:
+ return 4_000;
+ }
+ }
+
+ async embedTextInput(textInput) {
+ const result = await this.voyage.embedDocuments(
+ Array.isArray(textInput) ? textInput : [textInput]
+ );
+
+ // If given an array return the native Array[Array] format since that should be the outcome.
+ // But if given a single string, we need to flatten it so that we have a 1D array.
+ return (Array.isArray(textInput) ? result : result.flat()) || [];
+ }
+
+ async embedChunks(textChunks = []) {
+ try {
+ const embeddings = await this.voyage.embedDocuments(textChunks);
+ return embeddings;
+ } catch (error) {
+ console.error("Voyage AI Failed to embed:", error);
+ if (
+ error.message.includes(
+ "Cannot read properties of undefined (reading '0')"
+ )
+ )
+ throw new Error("Voyage AI failed to embed: Rate limit reached");
+ throw error;
+ }
+ }
+}
+
+module.exports = {
+ VoyageAiEmbedder,
+};
diff --git a/server/utils/EmbeddingRerankers/native/index.js b/server/utils/EmbeddingRerankers/native/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..fdade34021555e86690d3ff140bb0a2514ff1aba
--- /dev/null
+++ b/server/utils/EmbeddingRerankers/native/index.js
@@ -0,0 +1,257 @@
+const path = require("path");
+const fs = require("fs");
+
+class NativeEmbeddingReranker {
+ static #model = null;
+ static #tokenizer = null;
+ static #transformers = null;
+ static #initializationPromise = null;
+
+ // This is a folder that Mintplex Labs hosts for those who cannot capture the HF model download
+ // endpoint for various reasons. This endpoint is not guaranteed to be active or maintained
+ // and may go offline at any time at Mintplex Labs's discretion.
+ #fallbackHost = "https://cdn.anythingllm.com/support/models/";
+
+ constructor() {
+ // An alternative model to the mixedbread-ai/mxbai-rerank-xsmall-v1 model (speed on CPU is much slower for this model @ 18docs = 6s)
+ // Model Card: https://huggingface.co/Xenova/ms-marco-MiniLM-L-6-v2 (speed on CPU is much faster @ 18docs = 1.6s)
+ this.model = "Xenova/ms-marco-MiniLM-L-6-v2";
+ this.cacheDir = path.resolve(
+ process.env.STORAGE_DIR
+ ? path.resolve(process.env.STORAGE_DIR, `models`)
+ : path.resolve(__dirname, `../../../storage/models`)
+ );
+ this.modelPath = path.resolve(this.cacheDir, ...this.model.split("/"));
+ // Make directory when it does not exist in existing installations
+ if (!fs.existsSync(this.cacheDir)) fs.mkdirSync(this.cacheDir);
+
+ this.modelDownloaded = fs.existsSync(
+ path.resolve(this.cacheDir, this.model)
+ );
+ this.log("Initialized");
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[NativeEmbeddingReranker]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * This function will return the host of the current reranker suite.
+ * If the reranker suite is not initialized, it will return the default HF host.
+ * @returns {string} The host of the current reranker suite.
+ */
+ get host() {
+ if (!NativeEmbeddingReranker.#transformers) return "https://huggingface.co";
+ try {
+ return new URL(NativeEmbeddingReranker.#transformers.env.remoteHost).host;
+ } catch (e) {
+ return this.#fallbackHost;
+ }
+ }
+
+ /**
+ * This function will preload the reranker suite and tokenizer.
+ * This is useful for reducing the latency of the first rerank call and pre-downloading the models and such
+ * to avoid having to wait for the models to download on the first rerank call.
+ */
+ async preload() {
+ try {
+ this.log(`Preloading reranker suite...`);
+ await this.initClient();
+ this.log(
+ `Preloaded reranker suite. Reranking is available as a service now.`
+ );
+ return;
+ } catch (e) {
+ console.error(e);
+ this.log(
+ `Failed to preload reranker suite. Reranking will be available on the first rerank call.`
+ );
+ return;
+ }
+ }
+
+ async initClient() {
+ if (
+ NativeEmbeddingReranker.#transformers &&
+ NativeEmbeddingReranker.#model &&
+ NativeEmbeddingReranker.#tokenizer
+ ) {
+ this.log(`Reranker suite already fully initialized - reusing.`);
+ return;
+ }
+
+ if (NativeEmbeddingReranker.#initializationPromise) {
+ this.log(`Waiting for existing initialization to complete...`);
+ await NativeEmbeddingReranker.#initializationPromise;
+ return;
+ }
+
+ NativeEmbeddingReranker.#initializationPromise = (async () => {
+ try {
+ const { AutoModelForSequenceClassification, AutoTokenizer, env } =
+ await import("@xenova/transformers");
+ this.log(`Loading reranker suite...`);
+ NativeEmbeddingReranker.#transformers = {
+ AutoModelForSequenceClassification,
+ AutoTokenizer,
+ env,
+ };
+ // Attempt to load the model and tokenizer in this order:
+ // 1. From local file system cache
+ // 2. Download and cache from remote host (hf.co)
+ // 3. Download and cache from fallback host (cdn.anythingllm.com)
+ await this.#getPreTrainedModel();
+ await this.#getPreTrainedTokenizer();
+ } finally {
+ NativeEmbeddingReranker.#initializationPromise = null;
+ }
+ })();
+
+ await NativeEmbeddingReranker.#initializationPromise;
+ }
+
+ /**
+ * This function will load the model from the local file system cache, or download and cache it from the remote host.
+ * If the model is not found in the local file system cache, it will download and cache it from the remote host.
+ * If the model is not found in the remote host, it will download and cache it from the fallback host.
+ * @returns {Promise} The loaded model.
+ */
+ async #getPreTrainedModel() {
+ if (NativeEmbeddingReranker.#model) {
+ this.log(`Loading model from singleton...`);
+ return NativeEmbeddingReranker.#model;
+ }
+
+ try {
+ const model =
+ await NativeEmbeddingReranker.#transformers.AutoModelForSequenceClassification.from_pretrained(
+ this.model,
+ {
+ progress_callback: (p) => {
+ if (!this.modelDownloaded && p.status === "progress") {
+ this.log(
+ `[${this.host}] Loading model ${this.model}... ${p?.progress}%`
+ );
+ }
+ },
+ cache_dir: this.cacheDir,
+ }
+ );
+ this.log(`Loaded model ${this.model}`);
+ NativeEmbeddingReranker.#model = model;
+ return model;
+ } catch (e) {
+ this.log(
+ `Failed to load model ${this.model} from ${this.host}.`,
+ e.message,
+ e.stack
+ );
+ if (
+ NativeEmbeddingReranker.#transformers.env.remoteHost ===
+ this.#fallbackHost
+ ) {
+ this.log(`Failed to load model ${this.model} from fallback host.`);
+ throw e;
+ }
+
+ this.log(`Falling back to fallback host. ${this.#fallbackHost}`);
+ NativeEmbeddingReranker.#transformers.env.remoteHost = this.#fallbackHost;
+ NativeEmbeddingReranker.#transformers.env.remotePathTemplate = "{model}/";
+ return await this.#getPreTrainedModel();
+ }
+ }
+
+ /**
+ * This function will load the tokenizer from the local file system cache, or download and cache it from the remote host.
+ * If the tokenizer is not found in the local file system cache, it will download and cache it from the remote host.
+ * If the tokenizer is not found in the remote host, it will download and cache it from the fallback host.
+ * @returns {Promise} The loaded tokenizer.
+ */
+ async #getPreTrainedTokenizer() {
+ if (NativeEmbeddingReranker.#tokenizer) {
+ this.log(`Loading tokenizer from singleton...`);
+ return NativeEmbeddingReranker.#tokenizer;
+ }
+
+ try {
+ const tokenizer =
+ await NativeEmbeddingReranker.#transformers.AutoTokenizer.from_pretrained(
+ this.model,
+ {
+ progress_callback: (p) => {
+ if (!this.modelDownloaded && p.status === "progress") {
+ this.log(
+ `[${this.host}] Loading tokenizer ${this.model}... ${p?.progress}%`
+ );
+ }
+ },
+ cache_dir: this.cacheDir,
+ }
+ );
+ this.log(`Loaded tokenizer ${this.model}`);
+ NativeEmbeddingReranker.#tokenizer = tokenizer;
+ return tokenizer;
+ } catch (e) {
+ this.log(
+ `Failed to load tokenizer ${this.model} from ${this.host}.`,
+ e.message,
+ e.stack
+ );
+ if (
+ NativeEmbeddingReranker.#transformers.env.remoteHost ===
+ this.#fallbackHost
+ ) {
+ this.log(`Failed to load tokenizer ${this.model} from fallback host.`);
+ throw e;
+ }
+
+ this.log(`Falling back to fallback host. ${this.#fallbackHost}`);
+ NativeEmbeddingReranker.#transformers.env.remoteHost = this.#fallbackHost;
+ NativeEmbeddingReranker.#transformers.env.remotePathTemplate = "{model}/";
+ return await this.#getPreTrainedTokenizer();
+ }
+ }
+
+ /**
+ * Reranks a list of documents based on the query.
+ * @param {string} query - The query to rerank the documents against.
+ * @param {{text: string}[]} documents - The list of document text snippets to rerank. Should be output from a vector search.
+ * @param {Object} options - The options for the reranking.
+ * @param {number} options.topK - The number of top documents to return.
+ * @returns {Promise} - The reranked list of documents.
+ */
+ async rerank(query, documents, options = { topK: 4 }) {
+ await this.initClient();
+ const model = NativeEmbeddingReranker.#model;
+ const tokenizer = NativeEmbeddingReranker.#tokenizer;
+
+ const start = Date.now();
+ this.log(`Reranking ${documents.length} documents...`);
+ const inputs = tokenizer(new Array(documents.length).fill(query), {
+ text_pair: documents.map((doc) => doc.text),
+ padding: true,
+ truncation: true,
+ });
+ const { logits } = await model(inputs);
+ const reranked = logits
+ .sigmoid()
+ .tolist()
+ .map(([score], i) => ({
+ rerank_corpus_id: i,
+ rerank_score: score,
+ ...documents[i],
+ }))
+ .sort((a, b) => b.rerank_score - a.rerank_score)
+ .slice(0, options.topK);
+
+ this.log(
+ `Reranking ${documents.length} documents to top ${options.topK} took ${Date.now() - start}ms`
+ );
+ return reranked;
+ }
+}
+
+module.exports = {
+ NativeEmbeddingReranker,
+};
diff --git a/server/utils/EncryptionManager/index.js b/server/utils/EncryptionManager/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..8ef5619efaff8a99f077b3765fe2fa78d7de5823
--- /dev/null
+++ b/server/utils/EncryptionManager/index.js
@@ -0,0 +1,85 @@
+const crypto = require("crypto");
+const { dumpENV } = require("../helpers/updateENV");
+
+// Class that is used to arbitrarily encrypt/decrypt string data via a persistent passphrase/salt that
+// is either user defined or is created and saved to the ENV on creation.
+class EncryptionManager {
+ #keyENV = "SIG_KEY";
+ #saltENV = "SIG_SALT";
+ #encryptionKey;
+ #encryptionSalt;
+
+ constructor({ key = null, salt = null } = {}) {
+ this.#loadOrCreateKeySalt(key, salt);
+ this.key = crypto.scryptSync(this.#encryptionKey, this.#encryptionSalt, 32);
+ this.algorithm = "aes-256-cbc";
+ this.separator = ":";
+
+ // Used to send key to collector process to be able to decrypt data since they do not share ENVs
+ // this value should use the CommunicationKey.encrypt process before sending anywhere outside the
+ // server process so it is never sent in its raw format.
+ this.xPayload = this.key.toString("base64");
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[EncryptionManager]\x1b[0m ${text}`, ...args);
+ }
+
+ #loadOrCreateKeySalt(_key = null, _salt = null) {
+ if (!!_key && !!_salt) {
+ this.log(
+ "Pre-assigned key & salt for encrypting arbitrary data was used."
+ );
+ this.#encryptionKey = _key;
+ this.#encryptionSalt = _salt;
+ return;
+ }
+
+ if (!process.env[this.#keyENV] || !process.env[this.#saltENV]) {
+ this.log("Self-assigning key & salt for encrypting arbitrary data.");
+ process.env[this.#keyENV] = crypto.randomBytes(32).toString("hex");
+ process.env[this.#saltENV] = crypto.randomBytes(32).toString("hex");
+ if (process.env.NODE_ENV === "production") dumpENV();
+ } else
+ this.log("Loaded existing key & salt for encrypting arbitrary data.");
+
+ this.#encryptionKey = process.env[this.#keyENV];
+ this.#encryptionSalt = process.env[this.#saltENV];
+ return;
+ }
+
+ encrypt(plainTextString = null) {
+ try {
+ if (!plainTextString)
+ throw new Error("Empty string is not valid for this method.");
+ const iv = crypto.randomBytes(16);
+ const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
+ const encrypted = cipher.update(plainTextString, "utf8", "hex");
+ return [
+ encrypted + cipher.final("hex"),
+ Buffer.from(iv).toString("hex"),
+ ].join(this.separator);
+ } catch (e) {
+ this.log(e);
+ return null;
+ }
+ }
+
+ decrypt(encryptedString) {
+ try {
+ const [encrypted, iv] = encryptedString.split(this.separator);
+ if (!iv) throw new Error("IV not found");
+ const decipher = crypto.createDecipheriv(
+ this.algorithm,
+ this.key,
+ Buffer.from(iv, "hex")
+ );
+ return decipher.update(encrypted, "hex", "utf8") + decipher.final("utf8");
+ } catch (e) {
+ this.log(e);
+ return null;
+ }
+ }
+}
+
+module.exports = { EncryptionManager };
diff --git a/server/utils/MCP/hypervisor/index.js b/server/utils/MCP/hypervisor/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..0cd1f6cecbbb3e06703de35755ea910303e64d14
--- /dev/null
+++ b/server/utils/MCP/hypervisor/index.js
@@ -0,0 +1,443 @@
+const { safeJsonParse } = require("../../http");
+const path = require("path");
+const fs = require("fs");
+const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
+const {
+ StdioClientTransport,
+} = require("@modelcontextprotocol/sdk/client/stdio.js");
+const {
+ SSEClientTransport,
+} = require("@modelcontextprotocol/sdk/client/sse.js");
+const {
+ StreamableHTTPClientTransport,
+} = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
+
+/**
+ * @typedef {'stdio' | 'http' | 'sse'} MCPServerTypes
+ */
+
+/**
+ * @class MCPHypervisor
+ * @description A class that manages MCP servers found in the storage/plugins/anythingllm_mcp_servers.json file.
+ * This class is responsible for booting, stopping, and reloading MCP servers - it is the user responsibility for the MCP server definitions
+ * to me correct and also functioning tools depending on their deployment (docker vs local) as well as the security of said tools
+ * since MCP is basically arbitrary code execution.
+ *
+ * @notice This class is a singleton.
+ * @notice Each MCP tool has dependencies specific to it and this call WILL NOT check for them.
+ * For example, if the tools requires `npx` then the context in which AnythingLLM mains process is running will need to access npx.
+ * This is typically not common in our pre-built image so may not function. But this is the case anywhere MCP is used.
+ *
+ * AnythingLLM will take care of porting MCP servers to agent-callable functions via @agent directive.
+ * @see MCPCompatibilityLayer.convertServerToolsToPlugins
+ */
+class MCPHypervisor {
+ static _instance;
+ /**
+ * The path to the JSON file containing the MCP server definitions.
+ * @type {string}
+ */
+ mcpServerJSONPath;
+
+ /**
+ * The MCP servers currently running.
+ * @type { { [key: string]: Client & {transport: {_process: import('child_process').ChildProcess}, aibitatToolIds: string[]} } }
+ */
+ mcps = {};
+ /**
+ * The results of the MCP server loading process.
+ * @type { { [key: string]: {status: 'success' | 'failed', message: string} } }
+ */
+ mcpLoadingResults = {};
+
+ constructor() {
+ if (MCPHypervisor._instance) return MCPHypervisor._instance;
+ MCPHypervisor._instance = this;
+ this.log("Initializing MCP Hypervisor - subsequent calls will boot faster");
+ this.#setupConfigFile();
+ return this;
+ }
+
+ /**
+ * Setup the MCP server definitions file.
+ * Will create the file/directory if it doesn't exist already in storage/plugins with blank options
+ */
+ #setupConfigFile() {
+ this.mcpServerJSONPath =
+ process.env.NODE_ENV === "development"
+ ? path.resolve(
+ __dirname,
+ `../../../storage/plugins/anythingllm_mcp_servers.json`
+ )
+ : path.resolve(
+ process.env.STORAGE_DIR ??
+ path.resolve(__dirname, `../../../storage`),
+ `plugins/anythingllm_mcp_servers.json`
+ );
+
+ if (!fs.existsSync(this.mcpServerJSONPath)) {
+ fs.mkdirSync(path.dirname(this.mcpServerJSONPath), { recursive: true });
+ fs.writeFileSync(
+ this.mcpServerJSONPath,
+ JSON.stringify({ mcpServers: {} }, null, 2),
+ { encoding: "utf8" }
+ );
+ }
+
+ this.log(`MCP Config File: ${this.mcpServerJSONPath}`);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Get the MCP servers from the JSON file.
+ * @returns { { name: string, server: { command: string, args: string[], env: { [key: string]: string } } }[] } The MCP servers.
+ */
+ get mcpServerConfigs() {
+ const servers = safeJsonParse(
+ fs.readFileSync(this.mcpServerJSONPath, "utf8"),
+ { mcpServers: {} }
+ );
+ return Object.entries(servers.mcpServers).map(([name, server]) => ({
+ name,
+ server,
+ }));
+ }
+
+ /**
+ * Remove the MCP server from the config file
+ * @param {string} name - The name of the MCP server to remove
+ * @returns {boolean} - True if the MCP server was removed, false otherwise
+ */
+ removeMCPServerFromConfig(name) {
+ const servers = safeJsonParse(
+ fs.readFileSync(this.mcpServerJSONPath, "utf8"),
+ { mcpServers: {} }
+ );
+ if (!servers.mcpServers[name]) return false;
+
+ delete servers.mcpServers[name];
+ fs.writeFileSync(
+ this.mcpServerJSONPath,
+ JSON.stringify(servers, null, 2),
+ "utf8"
+ );
+ this.log(`MCP server ${name} removed from config file`);
+ return true;
+ }
+
+ /**
+ * Reload the MCP servers - can be used to reload the MCP servers without restarting the server or app
+ * and will also apply changes to the config file if any where made.
+ */
+ async reloadMCPServers() {
+ this.pruneMCPServers();
+ await this.bootMCPServers();
+ }
+
+ /**
+ * Start a single MCP server by its server name - public method
+ * @param {string} name - The name of the MCP server to start
+ * @returns {Promise<{success: boolean, error: string | null}>}
+ */
+ async startMCPServer(name) {
+ if (this.mcps[name])
+ return { success: false, error: `MCP server ${name} already running` };
+ const config = this.mcpServerConfigs.find((s) => s.name === name);
+ if (!config)
+ return {
+ success: false,
+ error: `MCP server ${name} not found in config file`,
+ };
+
+ try {
+ await this.#startMCPServer(config);
+ this.mcpLoadingResults[name] = {
+ status: "success",
+ message: `Successfully connected to MCP server: ${name}`,
+ };
+
+ return { success: true, message: `MCP server ${name} started` };
+ } catch (e) {
+ this.log(`Failed to start single MCP server: ${name}`, {
+ error: e.message,
+ code: e.code,
+ syscall: e.syscall,
+ path: e.path,
+ stack: e.stack,
+ });
+ this.mcpLoadingResults[name] = {
+ status: "failed",
+ message: `Failed to start MCP server: ${name} [${e.code || "NO_CODE"}] ${e.message}`,
+ };
+
+ // Clean up failed connection
+ if (this.mcps[name]) {
+ this.mcps[name].close();
+ delete this.mcps[name];
+ }
+
+ return { success: false, error: e.message };
+ }
+ }
+ /**
+ * Prune a single MCP server by its server name
+ * @param {string} name - The name of the MCP server to prune
+ * @returns {boolean} - True if the MCP server was pruned, false otherwise
+ */
+ pruneMCPServer(name) {
+ if (!name || !this.mcps[name]) return true;
+
+ this.log(`Pruning MCP server: ${name}`);
+ const mcp = this.mcps[name];
+ const childProcess = mcp.transport._process;
+ if (childProcess) childProcess.kill(1);
+ mcp.transport.close();
+
+ delete this.mcps[name];
+ this.mcpLoadingResults[name] = {
+ status: "failed",
+ message: `Server was stopped manually by the administrator.`,
+ };
+ return true;
+ }
+
+ /**
+ * Prune the MCP servers - pkills and forgets all MCP servers
+ * @returns {void}
+ */
+ pruneMCPServers() {
+ this.log(`Pruning ${Object.keys(this.mcps).length} MCP servers...`);
+
+ for (const name of Object.keys(this.mcps)) {
+ if (!this.mcps[name]) continue;
+ const mcp = this.mcps[name];
+ const childProcess = mcp.transport._process;
+ if (childProcess)
+ this.log(`Killing MCP ${name} (PID: ${childProcess.pid})`, {
+ killed: childProcess.kill(1),
+ });
+
+ mcp.transport.close();
+ mcp.close();
+ }
+ this.mcps = {};
+ this.mcpLoadingResults = {};
+ }
+
+ /**
+ * Build the MCP server environment variables - ensures proper PATH and NODE_PATH
+ * inheritance across all platforms and deployment scenarios.
+ * @param {Object} server - The server definition
+ * @returns {{env: { [key: string]: string } | {}}} - The environment variables
+ */
+ #buildMCPServerENV(server) {
+ // Start with essential environment variables, inheriting from current process
+ // This ensures GUI applications on macOS/Linux get proper PATH inheritance
+ let baseEnv = {
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
+ NODE_PATH: process.env.NODE_PATH || "/usr/local/lib/node_modules",
+ };
+
+ // Docker-specific environment setup
+ if (process.env.ANYTHING_LLM_RUNTIME === "docker") {
+ baseEnv = {
+ // Fixed: NODE_PATH should point to modules directory, not node binary
+ NODE_PATH: "/usr/local/lib/node_modules",
+ PATH: "/usr/local/bin:/usr/bin:/bin",
+ ...baseEnv, // Allow inheritance to override docker defaults if needed
+ };
+ }
+
+ // No custom environment specified - return base environment
+ if (!server?.env || Object.keys(server.env).length === 0) {
+ return { env: baseEnv };
+ }
+
+ // Merge user-specified environment with base environment
+ // User environment takes precedence over defaults
+ return {
+ env: {
+ ...baseEnv,
+ ...server.env,
+ },
+ };
+ }
+
+ /**
+ * Parse the server type from the server definition
+ * @param {Object} server - The server definition
+ * @returns {MCPServerTypes | null} - The server type
+ */
+ #parseServerType(server) {
+ if (server.hasOwnProperty("command")) return "stdio";
+ if (server.hasOwnProperty("url")) return "http";
+ return "sse";
+ }
+
+ /**
+ * Validate the server definition by type
+ * - Will throw an error if the server definition is invalid
+ * @param {Object} server - The server definition
+ * @param {MCPServerTypes} type - The server type
+ * @returns {void}
+ */
+ #validateServerDefinitionByType(server, type) {
+ if (type === "stdio") {
+ if (server.hasOwnProperty("args") && !Array.isArray(server.args))
+ throw new Error("MCP server args must be an array");
+ }
+
+ if (type === "http") {
+ if (!["sse", "streamable"].includes(server?.type))
+ throw new Error("MCP server type must have sse or streamable value.");
+ }
+
+ if (type === "sse") return;
+ return;
+ }
+
+ /**
+ * Setup the server transport by type and server definition
+ * @param {Object} server - The server definition
+ * @param {MCPServerTypes} type - The server type
+ * @returns {StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport} - The server transport
+ */
+ #setupServerTransport(server, type) {
+ // if not stdio then it is http or sse
+ if (type !== "stdio") return this.createHttpTransport(server);
+
+ return new StdioClientTransport({
+ command: server.command,
+ args: server?.args ?? [],
+ ...this.#buildMCPServerENV(server),
+ });
+ }
+
+ /**
+ * Create MCP client transport for http MCP server.
+ * @param {Object} server - The server definition
+ * @returns {StreamableHTTPClientTransport | SSEClientTransport} - The server transport
+ */
+ createHttpTransport(server) {
+ const url = new URL(server.url);
+
+ // If the server block has a type property then use that to determine the transport type
+ switch (server.type) {
+ case "streamable":
+ return new StreamableHTTPClientTransport(url, {
+ requestInit: {
+ headers: server.headers,
+ },
+ });
+ default:
+ return new SSEClientTransport(url, {
+ requestInit: {
+ headers: server.headers,
+ },
+ });
+ }
+ }
+
+ /**
+ * @private Start a single MCP server by its server definition from the JSON file
+ * @param {string} name - The name of the MCP server to start
+ * @param {Object} server - The server definition
+ * @returns {Promise}
+ */
+ async #startMCPServer({ name, server }) {
+ if (!name) throw new Error("MCP server name is required");
+ if (!server) throw new Error("MCP server definition is required");
+ const serverType = this.#parseServerType(server);
+ if (!serverType) throw new Error("MCP server command or url is required");
+
+ this.#validateServerDefinitionByType(server, serverType);
+ this.log(`Attempting to start MCP server: ${name}`);
+ const mcp = new Client({ name: name, version: "1.0.0" });
+ const transport = this.#setupServerTransport(server, serverType);
+
+ // Add connection event listeners
+ transport.onclose = () => this.log(`${name} - Transport closed`);
+ transport.onerror = (error) =>
+ this.log(`${name} - Transport error:`, error);
+ transport.onmessage = (message) =>
+ this.log(`${name} - Transport message:`, message);
+
+ // Connect and await the connection with a timeout
+ this.mcps[name] = mcp;
+ const connectionPromise = mcp.connect(transport);
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(() => reject(new Error("Connection timeout")), 30_000); // 30 second timeout
+ });
+ await Promise.race([connectionPromise, timeoutPromise]);
+ return true;
+ }
+
+ /**
+ * Boot the MCP servers according to the server definitions.
+ * This function will skip booting MCP servers if they are already running.
+ * @returns { Promise<{ [key: string]: {status: string, message: string} }> } The results of the boot process.
+ */
+ async bootMCPServers() {
+ if (Object.keys(this.mcps).length > 0) {
+ this.log("MCP Servers already running, skipping boot.");
+ return this.mcpLoadingResults;
+ }
+
+ const serverDefinitions = this.mcpServerConfigs;
+ for (const { name, server } of serverDefinitions) {
+ if (
+ server.anythingllm?.hasOwnProperty("autoStart") &&
+ server.anythingllm.autoStart === false
+ ) {
+ this.log(
+ `MCP server ${name} has anythingllm.autoStart property set to false, skipping boot!`
+ );
+ this.mcpLoadingResults[name] = {
+ status: "failed",
+ message: `MCP server ${name} has anythingllm.autoStart property set to false, boot skipped!`,
+ };
+ continue;
+ }
+
+ try {
+ await this.#startMCPServer({ name, server });
+ // Verify the connection is alive?
+ // if (!(await mcp.ping())) throw new Error('Connection failed to establish');
+ this.mcpLoadingResults[name] = {
+ status: "success",
+ message: `Successfully connected to MCP server: ${name}`,
+ };
+ } catch (e) {
+ this.log(`Failed to start MCP server: ${name}`, {
+ error: e.message,
+ code: e.code,
+ syscall: e.syscall,
+ path: e.path,
+ stack: e.stack, // Adding stack trace for better debugging
+ });
+ this.mcpLoadingResults[name] = {
+ status: "failed",
+ message: `Failed to start MCP server: ${name} [${e.code || "NO_CODE"}] ${e.message}`,
+ };
+
+ // Clean up failed connection
+ if (this.mcps[name]) {
+ this.mcps[name].close();
+ delete this.mcps[name];
+ }
+ }
+ }
+
+ const runningServers = Object.keys(this.mcps);
+ this.log(
+ `Successfully started ${runningServers.length} MCP servers:`,
+ runningServers
+ );
+ return this.mcpLoadingResults;
+ }
+}
+
+module.exports = MCPHypervisor;
diff --git a/server/utils/MCP/index.js b/server/utils/MCP/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..03e72a860784c93f06e961d0c324065ffb47b53e
--- /dev/null
+++ b/server/utils/MCP/index.js
@@ -0,0 +1,203 @@
+const MCPHypervisor = require("./hypervisor");
+
+class MCPCompatibilityLayer extends MCPHypervisor {
+ static _instance;
+
+ constructor() {
+ super();
+ if (MCPCompatibilityLayer._instance) return MCPCompatibilityLayer._instance;
+ MCPCompatibilityLayer._instance = this;
+ }
+
+ /**
+ * Get all of the active MCP servers as plugins we can load into agents.
+ * This will also boot all MCP servers if they have not been started yet.
+ * @returns {Promise} Array of flow names in @@mcp_{name} format
+ */
+ async activeMCPServers() {
+ await this.bootMCPServers();
+ return Object.keys(this.mcps).flatMap((name) => `@@mcp_${name}`);
+ }
+
+ /**
+ * Convert an MCP server name to an AnythingLLM Agent plugin
+ * @param {string} name - The base name of the MCP server to convert - not the tool name. eg: `docker-mcp` not `docker-mcp:list-containers`
+ * @param {Object} aibitat - The aibitat object to pass to the plugin
+ * @returns {Promise<{name: string, description: string, plugin: Function}[]|null>} Array of plugin configurations or null if not found
+ */
+ async convertServerToolsToPlugins(name, _aibitat = null) {
+ const mcp = this.mcps[name];
+ if (!mcp) return null;
+
+ const tools = (await mcp.listTools()).tools;
+ if (!tools.length) return null;
+
+ const plugins = [];
+ for (const tool of tools) {
+ plugins.push({
+ name: `${name}-${tool.name}`,
+ description: tool.description,
+ plugin: function () {
+ return {
+ name: `${name}-${tool.name}`,
+ setup: (aibitat) => {
+ aibitat.function({
+ super: aibitat,
+ name: `${name}-${tool.name}`,
+ controller: new AbortController(),
+ description: tool.description,
+ examples: [],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ ...tool.inputSchema,
+ },
+ handler: async function (args = {}) {
+ try {
+ aibitat.handlerProps.log(
+ `Executing MCP server: ${name}:${tool.name} with args:`,
+ args
+ );
+ aibitat.introspect(
+ `Executing MCP server: ${name} with ${JSON.stringify(args, null, 2)}`
+ );
+ const result = await mcp.callTool({
+ name: tool.name,
+ arguments: args,
+ });
+ aibitat.handlerProps.log(
+ `MCP server: ${name}:${tool.name} completed successfully`,
+ result
+ );
+ aibitat.introspect(
+ `MCP server: ${name}:${tool.name} completed successfully`
+ );
+ return typeof result === "object"
+ ? JSON.stringify(result)
+ : String(result);
+ } catch (error) {
+ aibitat.handlerProps.log(
+ `MCP server: ${name}:${tool.name} failed with error:`,
+ error
+ );
+ aibitat.introspect(
+ `MCP server: ${name}:${tool.name} failed with error:`,
+ error
+ );
+ return `The tool ${name}:${tool.name} failed with error: ${error?.message || "An unknown error occurred"}`;
+ }
+ },
+ });
+ },
+ };
+ },
+ toolName: `${name}:${tool.name}`,
+ });
+ }
+
+ return plugins;
+ }
+
+ /**
+ * Returns the MCP servers that were loaded or attempted to be loaded
+ * so that we can display them in the frontend for review or error logging.
+ * @returns {Promise<{
+ * name: string,
+ * running: boolean,
+ * tools: {name: string, description: string, inputSchema: Object}[],
+ * process: {pid: number, cmd: string}|null,
+ * error: string|null
+ * }[]>} - The active MCP servers
+ */
+ async servers() {
+ await this.bootMCPServers();
+ const servers = [];
+ for (const [name, result] of Object.entries(this.mcpLoadingResults)) {
+ const config = this.mcpServerConfigs.find((s) => s.name === name);
+
+ if (result.status === "failed") {
+ servers.push({
+ name,
+ config: config?.server || null,
+ running: false,
+ tools: [],
+ error: result.message,
+ process: null,
+ });
+ continue;
+ }
+
+ const mcp = this.mcps[name];
+ if (!mcp) {
+ delete this.mcpLoadingResults[name];
+ delete this.mcps[name];
+ continue;
+ }
+
+ const online = !!(await mcp.ping());
+ const tools = online ? (await mcp.listTools()).tools : [];
+ servers.push({
+ name,
+ config: config?.server || null,
+ running: online,
+ tools,
+ error: null,
+ process: {
+ pid: mcp.transport?.process?.pid || null,
+ },
+ });
+ }
+ return servers;
+ }
+
+ /**
+ * Toggle the MCP server (start or stop)
+ * @param {string} name - The name of the MCP server to toggle
+ * @returns {Promise<{success: boolean, error: string | null}>}
+ */
+ async toggleServerStatus(name) {
+ const server = this.mcpServerConfigs.find((s) => s.name === name);
+ if (!server)
+ return {
+ success: false,
+ error: `MCP server ${name} not found in config file.`,
+ };
+ const mcp = this.mcps[name];
+ const online = !!mcp ? !!(await mcp.ping()) : false; // If the server is not in the mcps object, it is not running
+
+ if (online) {
+ const killed = this.pruneMCPServer(name);
+ return {
+ success: killed,
+ error: killed ? null : `Failed to kill MCP server: ${name}`,
+ };
+ } else {
+ const startupResult = await this.startMCPServer(name);
+ return { success: startupResult.success, error: startupResult.error };
+ }
+ }
+
+ /**
+ * Delete the MCP server - will also remove it from the config file
+ * @param {string} name - The name of the MCP server to delete
+ * @returns {Promise<{success: boolean, error: string | null}>}
+ */
+ async deleteServer(name) {
+ const server = this.mcpServerConfigs.find((s) => s.name === name);
+ if (!server)
+ return {
+ success: false,
+ error: `MCP server ${name} not found in config file.`,
+ };
+
+ const mcp = this.mcps[name];
+ const online = !!mcp ? !!(await mcp.ping()) : false; // If the server is not in the mcps object, it is not running
+ if (online) this.pruneMCPServer(name);
+ this.removeMCPServerFromConfig(name);
+
+ delete this.mcps[name];
+ delete this.mcpLoadingResults[name];
+ this.log(`MCP server was killed and removed from config file: ${name}`);
+ return { success: true, error: null };
+ }
+}
+module.exports = MCPCompatibilityLayer;
diff --git a/server/utils/PasswordRecovery/index.js b/server/utils/PasswordRecovery/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..2383dd2c3f3ec2bb24beba4d7b0c5dd1d380556b
--- /dev/null
+++ b/server/utils/PasswordRecovery/index.js
@@ -0,0 +1,103 @@
+const bcrypt = require("bcrypt");
+const { v4, validate } = require("uuid");
+const { User } = require("../../models/user");
+const {
+ RecoveryCode,
+ PasswordResetToken,
+} = require("../../models/passwordRecovery");
+
+async function generateRecoveryCodes(userId) {
+ const newRecoveryCodes = [];
+ const plainTextCodes = [];
+ for (let i = 0; i < 4; i++) {
+ const code = v4();
+ const hashedCode = bcrypt.hashSync(code, 10);
+ newRecoveryCodes.push({
+ user_id: userId,
+ code_hash: hashedCode,
+ });
+ plainTextCodes.push(code);
+ }
+
+ const { error } = await RecoveryCode.createMany(newRecoveryCodes);
+ if (!!error) throw new Error(error);
+
+ const { user: success } = await User._update(userId, {
+ seen_recovery_codes: true,
+ });
+ if (!success) throw new Error("Failed to generate user recovery codes!");
+
+ return plainTextCodes;
+}
+
+async function recoverAccount(username = "", recoveryCodes = []) {
+ const user = await User.get({ username: String(username) });
+ if (!user) return { success: false, error: "Invalid recovery codes." };
+
+ // If hashes do not exist for a user
+ // because this is a user who has not logged out and back in since upgrade.
+ const allUserHashes = await RecoveryCode.hashesForUser(user.id);
+ if (allUserHashes.length < 4)
+ return { success: false, error: "Invalid recovery codes" };
+
+ // If they tried to send more than two unique codes, we only take the first two
+ const uniqueRecoveryCodes = [...new Set(recoveryCodes)]
+ .map((code) => code.trim())
+ .filter((code) => validate(code)) // we know that any provided code must be a uuid v4.
+ .slice(0, 2);
+ if (uniqueRecoveryCodes.length !== 2)
+ return { success: false, error: "Invalid recovery codes." };
+
+ const validCodes = uniqueRecoveryCodes.every((code) => {
+ let valid = false;
+ allUserHashes.forEach((hash) => {
+ if (bcrypt.compareSync(code, hash)) valid = true;
+ });
+ return valid;
+ });
+ if (!validCodes) return { success: false, error: "Invalid recovery codes" };
+
+ const { passwordResetToken, error } = await PasswordResetToken.create(
+ user.id
+ );
+ if (!!error) return { success: false, error };
+ return { success: true, resetToken: passwordResetToken.token };
+}
+
+async function resetPassword(token, _newPassword = "", confirmPassword = "") {
+ const newPassword = String(_newPassword).trim(); // No spaces in passwords
+ if (!newPassword) throw new Error("Invalid password.");
+ if (newPassword !== String(confirmPassword))
+ throw new Error("Passwords do not match");
+
+ const resetToken = await PasswordResetToken.findUnique({
+ token: String(token),
+ });
+ if (!resetToken || resetToken.expiresAt < new Date()) {
+ return { success: false, message: "Invalid reset token" };
+ }
+
+ // JOI password rules will be enforced inside .update.
+ const { error } = await User.update(resetToken.user_id, {
+ password: newPassword,
+ });
+
+ // seen_recovery_codes is not publicly writable
+ // so we have to do direct update here
+ await User._update(resetToken.user_id, {
+ seen_recovery_codes: false,
+ });
+
+ if (error) return { success: false, message: error };
+ await PasswordResetToken.deleteMany({ user_id: resetToken.user_id });
+ await RecoveryCode.deleteMany({ user_id: resetToken.user_id });
+
+ // New codes are provided on first new login.
+ return { success: true, message: "Password reset successful" };
+}
+
+module.exports = {
+ recoverAccount,
+ resetPassword,
+ generateRecoveryCodes,
+};
diff --git a/server/utils/TextSplitter/index.js b/server/utils/TextSplitter/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..c3f03bfb89abd57cb01ab5b47f49f9390257a1ad
--- /dev/null
+++ b/server/utils/TextSplitter/index.js
@@ -0,0 +1,206 @@
+/**
+ * @typedef {object} DocumentMetadata
+ * @property {string} id - eg; "123e4567-e89b-12d3-a456-426614174000"
+ * @property {string} url - eg; "file://example.com/index.html"
+ * @property {string} title - eg; "example.com/index.html"
+ * @property {string} docAuthor - eg; "no author found"
+ * @property {string} description - eg; "No description found."
+ * @property {string} docSource - eg; "URL link uploaded by the user."
+ * @property {string} chunkSource - eg; link://https://example.com
+ * @property {string} published - ISO 8601 date string
+ * @property {number} wordCount - Number of words in the document
+ * @property {string} pageContent - The raw text content of the document
+ * @property {number} token_count_estimate - Number of tokens in the document
+ */
+
+function isNullOrNaN(value) {
+ if (value === null) return true;
+ return isNaN(value);
+}
+
+class TextSplitter {
+ #splitter;
+
+ /**
+ * Creates a new TextSplitter instance.
+ * @param {Object} config
+ * @param {string} [config.chunkPrefix = ""] - Prefix to be added to the start of each chunk.
+ * @param {number} [config.chunkSize = 1000] - The size of each chunk.
+ * @param {number} [config.chunkOverlap = 20] - The overlap between chunks.
+ * @param {Object} [config.chunkHeaderMeta = null] - Metadata to be added to the start of each chunk - will come after the prefix.
+ */
+ constructor(config = {}) {
+ this.config = config;
+ this.#splitter = this.#setSplitter(config);
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[35m[TextSplitter]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Does a quick check to determine the text chunk length limit.
+ * Embedder models have hard-set limits that cannot be exceeded, just like an LLM context
+ * so here we want to allow override of the default 1000, but up to the models maximum, which is
+ * sometimes user defined.
+ */
+ static determineMaxChunkSize(preferred = null, embedderLimit = 1000) {
+ const prefValue = isNullOrNaN(preferred)
+ ? Number(embedderLimit)
+ : Number(preferred);
+ const limit = Number(embedderLimit);
+ if (prefValue > limit)
+ console.log(
+ `\x1b[43m[WARN]\x1b[0m Text splitter chunk length of ${prefValue} exceeds embedder model max of ${embedderLimit}. Will use ${embedderLimit}.`
+ );
+ return prefValue > limit ? limit : prefValue;
+ }
+
+ /**
+ * Creates a string of metadata to be prepended to each chunk.
+ * @param {DocumentMetadata} metadata - Metadata to be prepended to each chunk.
+ * @returns {{[key: ('title' | 'published' | 'source')]: string}} Object of metadata that will be prepended to each chunk.
+ */
+ static buildHeaderMeta(metadata = {}) {
+ if (!metadata || Object.keys(metadata).length === 0) return null;
+ const PLUCK_MAP = {
+ title: {
+ as: "sourceDocument",
+ pluck: (metadata) => {
+ return metadata?.title || null;
+ },
+ },
+ published: {
+ as: "published",
+ pluck: (metadata) => {
+ return metadata?.published || null;
+ },
+ },
+ chunkSource: {
+ as: "source",
+ pluck: (metadata) => {
+ const validPrefixes = ["link://", "youtube://"];
+ // If the chunkSource is a link or youtube link, we can add the URL
+ // as its source in the metadata so the LLM can use it for context.
+ // eg prompt: Where did you get this information? -> answer: "from https://example.com"
+ if (
+ !metadata?.chunkSource || // Exists
+ !metadata?.chunkSource.length || // Is not empty
+ typeof metadata.chunkSource !== "string" || // Is a string
+ !validPrefixes.some(
+ (prefix) => metadata.chunkSource.startsWith(prefix) // Has a valid prefix we respect
+ )
+ )
+ return null;
+
+ // We know a prefix is present, so we can split on it and return the rest.
+ // If nothing is found, return null and it will not be added to the metadata.
+ let source = null;
+ for (const prefix of validPrefixes) {
+ source = metadata.chunkSource.split(prefix)?.[1] || null;
+ if (source) break;
+ }
+
+ return source;
+ },
+ },
+ };
+
+ const pluckedData = {};
+ Object.entries(PLUCK_MAP).forEach(([key, value]) => {
+ if (!(key in metadata)) return; // Skip if the metadata key is not present.
+ const pluckedValue = value.pluck(metadata);
+ if (!pluckedValue) return; // Skip if the plucked value is null/empty.
+ pluckedData[value.as] = pluckedValue;
+ });
+
+ return pluckedData;
+ }
+
+ /**
+ * Apply the chunk prefix to the text if it is present.
+ * @param {string} text - The text to apply the prefix to.
+ * @returns {string} The text with the embedder model prefix applied.
+ */
+ #applyPrefix(text = "") {
+ if (!this.config.chunkPrefix) return text;
+ return `${this.config.chunkPrefix}${text}`;
+ }
+
+ /**
+ * Creates a string of metadata to be prepended to each chunk.
+ * Will additionally prepend a prefix to the text if it was provided (requirement for some embedders).
+ * @returns {string} The text with the embedder model prefix applied.
+ */
+ stringifyHeader() {
+ let content = "";
+ if (!this.config.chunkHeaderMeta) return this.#applyPrefix(content);
+ Object.entries(this.config.chunkHeaderMeta).map(([key, value]) => {
+ if (!key || !value) return;
+ content += `${key}: ${value}\n`;
+ });
+
+ if (!content) return this.#applyPrefix(content);
+ return this.#applyPrefix(
+ `\n${content} \n\n`
+ );
+ }
+
+ /**
+ * Sets the splitter to use a defined config passes to other subclasses.
+ * @param {Object} config
+ * @param {string} [config.chunkPrefix = ""] - Prefix to be added to the start of each chunk.
+ * @param {number} [config.chunkSize = 1000] - The size of each chunk.
+ * @param {number} [config.chunkOverlap = 20] - The overlap between chunks.
+ */
+ #setSplitter(config = {}) {
+ // if (!config?.splitByFilename) {// TODO do something when specific extension is present? }
+ return new RecursiveSplitter({
+ chunkSize: isNaN(config?.chunkSize) ? 1_000 : Number(config?.chunkSize),
+ chunkOverlap: isNaN(config?.chunkOverlap)
+ ? 20
+ : Number(config?.chunkOverlap),
+ chunkHeader: this.stringifyHeader(),
+ });
+ }
+
+ async splitText(documentText) {
+ return this.#splitter._splitText(documentText);
+ }
+}
+
+// Wrapper for Langchain default RecursiveCharacterTextSplitter class.
+class RecursiveSplitter {
+ constructor({ chunkSize, chunkOverlap, chunkHeader = null }) {
+ const {
+ RecursiveCharacterTextSplitter,
+ } = require("@langchain/textsplitters");
+ this.log(`Will split with`, {
+ chunkSize,
+ chunkOverlap,
+ chunkHeader: chunkHeader ? `${chunkHeader?.slice(0, 50)}...` : null,
+ });
+ this.chunkHeader = chunkHeader;
+ this.engine = new RecursiveCharacterTextSplitter({
+ chunkSize,
+ chunkOverlap,
+ });
+ }
+
+ log(text, ...args) {
+ console.log(`\x1b[35m[RecursiveSplitter]\x1b[0m ${text}`, ...args);
+ }
+
+ async _splitText(documentText) {
+ if (!this.chunkHeader) return this.engine.splitText(documentText);
+ const strings = await this.engine.splitText(documentText);
+ const documents = await this.engine.createDocuments(strings, [], {
+ chunkHeader: this.chunkHeader,
+ });
+ return documents
+ .filter((doc) => !!doc.pageContent)
+ .map((doc) => doc.pageContent);
+ }
+}
+
+module.exports.TextSplitter = TextSplitter;
diff --git a/server/utils/TextToSpeech/elevenLabs/index.js b/server/utils/TextToSpeech/elevenLabs/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e3d25f3ae9be5deb936435ae16eb71b6b1c7bfbc
--- /dev/null
+++ b/server/utils/TextToSpeech/elevenLabs/index.js
@@ -0,0 +1,54 @@
+const { ElevenLabsClient, stream } = require("elevenlabs");
+
+class ElevenLabsTTS {
+ constructor() {
+ if (!process.env.TTS_ELEVEN_LABS_KEY)
+ throw new Error("No ElevenLabs API key was set.");
+ this.elevenLabs = new ElevenLabsClient({
+ apiKey: process.env.TTS_ELEVEN_LABS_KEY,
+ });
+
+ // Rachel as default voice
+ // https://api.elevenlabs.io/v1/voices
+ this.voiceId =
+ process.env.TTS_ELEVEN_LABS_VOICE_MODEL ?? "21m00Tcm4TlvDq8ikWAM";
+ this.modelId = "eleven_multilingual_v2";
+ }
+
+ static async voices(apiKey = null) {
+ try {
+ const client = new ElevenLabsClient({
+ apiKey: apiKey ?? process.env.TTS_ELEVEN_LABS_KEY ?? null,
+ });
+ return (await client.voices.getAll())?.voices ?? [];
+ } catch {}
+ return [];
+ }
+
+ #stream2buffer(stream) {
+ return new Promise((resolve, reject) => {
+ const _buf = [];
+ stream.on("data", (chunk) => _buf.push(chunk));
+ stream.on("end", () => resolve(Buffer.concat(_buf)));
+ stream.on("error", (err) => reject(err));
+ });
+ }
+
+ async ttsBuffer(textInput) {
+ try {
+ const audio = await this.elevenLabs.generate({
+ voice: this.voiceId,
+ text: textInput,
+ model_id: "eleven_multilingual_v2",
+ });
+ return Buffer.from(await this.#stream2buffer(audio));
+ } catch (e) {
+ console.error(e);
+ }
+ return null;
+ }
+}
+
+module.exports = {
+ ElevenLabsTTS,
+};
diff --git a/server/utils/TextToSpeech/index.js b/server/utils/TextToSpeech/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ed5684de6d283467d8692ebacfda75cf945783d
--- /dev/null
+++ b/server/utils/TextToSpeech/index.js
@@ -0,0 +1,18 @@
+function getTTSProvider() {
+ const provider = process.env.TTS_PROVIDER || "openai";
+ switch (provider) {
+ case "openai":
+ const { OpenAiTTS } = require("./openAi");
+ return new OpenAiTTS();
+ case "elevenlabs":
+ const { ElevenLabsTTS } = require("./elevenLabs");
+ return new ElevenLabsTTS();
+ case "generic-openai":
+ const { GenericOpenAiTTS } = require("./openAiGeneric");
+ return new GenericOpenAiTTS();
+ default:
+ throw new Error("ENV: No TTS_PROVIDER value found in environment!");
+ }
+}
+
+module.exports = { getTTSProvider };
diff --git a/server/utils/TextToSpeech/openAi/index.js b/server/utils/TextToSpeech/openAi/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..3c5b4840d2816afe0fd8c4b64e4d7ae2d353db82
--- /dev/null
+++ b/server/utils/TextToSpeech/openAi/index.js
@@ -0,0 +1,29 @@
+class OpenAiTTS {
+ constructor() {
+ if (!process.env.TTS_OPEN_AI_KEY)
+ throw new Error("No OpenAI API key was set.");
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ apiKey: process.env.TTS_OPEN_AI_KEY,
+ });
+ this.voice = process.env.TTS_OPEN_AI_VOICE_MODEL ?? "alloy";
+ }
+
+ async ttsBuffer(textInput) {
+ try {
+ const result = await this.openai.audio.speech.create({
+ model: "tts-1",
+ voice: this.voice,
+ input: textInput,
+ });
+ return Buffer.from(await result.arrayBuffer());
+ } catch (e) {
+ console.error(e);
+ }
+ return null;
+ }
+}
+
+module.exports = {
+ OpenAiTTS,
+};
diff --git a/server/utils/TextToSpeech/openAiGeneric/index.js b/server/utils/TextToSpeech/openAiGeneric/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..5694ed33a38e3fe84dc8ab67f86a8f0c9739110d
--- /dev/null
+++ b/server/utils/TextToSpeech/openAiGeneric/index.js
@@ -0,0 +1,58 @@
+class GenericOpenAiTTS {
+ constructor() {
+ if (!process.env.TTS_OPEN_AI_COMPATIBLE_KEY)
+ this.#log(
+ "No OpenAI compatible API key was set. You might need to set this to use your OpenAI compatible TTS service."
+ );
+ if (!process.env.TTS_OPEN_AI_COMPATIBLE_MODEL)
+ this.#log(
+ "No OpenAI compatible TTS model was set. We will use the default voice model 'tts-1'. This may not exist or be valid your selected endpoint."
+ );
+ if (!process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL)
+ this.#log(
+ "No OpenAI compatible voice model was set. We will use the default voice model 'alloy'. This may not exist for your selected endpoint."
+ );
+ if (!process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT)
+ throw new Error(
+ "No OpenAI compatible endpoint was set. Please set this to use your OpenAI compatible TTS service."
+ );
+
+ const { OpenAI: OpenAIApi } = require("openai");
+ this.openai = new OpenAIApi({
+ apiKey: process.env.TTS_OPEN_AI_COMPATIBLE_KEY || null,
+ baseURL: process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT,
+ });
+ this.model = process.env.TTS_OPEN_AI_COMPATIBLE_MODEL ?? "tts-1";
+ this.voice = process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL ?? "alloy";
+ this.#log(
+ `Service (${process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT}) with model: ${this.model} and voice: ${this.voice}`
+ );
+ }
+
+ #log(text, ...args) {
+ console.log(`\x1b[32m[OpenAiGenericTTS]\x1b[0m ${text}`, ...args);
+ }
+
+ /**
+ * Generates a buffer from the given text input using the OpenAI compatible TTS service.
+ * @param {string} textInput - The text to be converted to audio.
+ * @returns {Promise} A buffer containing the audio data.
+ */
+ async ttsBuffer(textInput) {
+ try {
+ const result = await this.openai.audio.speech.create({
+ model: this.model,
+ voice: this.voice,
+ input: textInput,
+ });
+ return Buffer.from(await result.arrayBuffer());
+ } catch (e) {
+ console.error(e);
+ }
+ return null;
+ }
+}
+
+module.exports = {
+ GenericOpenAiTTS,
+};
diff --git a/server/utils/agentFlows/executor.js b/server/utils/agentFlows/executor.js
new file mode 100644
index 0000000000000000000000000000000000000000..c41ca9c490a603152f97c79f8e83cc798046cf67
--- /dev/null
+++ b/server/utils/agentFlows/executor.js
@@ -0,0 +1,235 @@
+const { FLOW_TYPES } = require("./flowTypes");
+const executeApiCall = require("./executors/api-call");
+const executeLLMInstruction = require("./executors/llm-instruction");
+const executeWebScraping = require("./executors/web-scraping");
+const { Telemetry } = require("../../models/telemetry");
+const { safeJsonParse } = require("../http");
+
+class FlowExecutor {
+ constructor() {
+ this.variables = {};
+ this.introspect = (...args) => console.log("[introspect] ", ...args);
+ this.logger = console.info;
+ this.aibitat = null;
+ }
+
+ attachLogging(introspectFn = null, loggerFn = null) {
+ this.introspect =
+ introspectFn || ((...args) => console.log("[introspect] ", ...args));
+ this.logger = loggerFn || console.info;
+ }
+
+ /**
+ * Resolves nested values from objects using dot notation and array indices
+ * Supports paths like "data.items[0].name" or "response.users[2].address.city"
+ * Returns undefined for invalid paths or errors
+ * @param {Object|string} obj - The object to resolve the value from
+ * @param {string} path - The path to the value
+ * @returns {string} The resolved value
+ */
+ getValueFromPath(obj = {}, path = "") {
+ if (typeof obj === "string") obj = safeJsonParse(obj, {});
+
+ if (
+ !obj ||
+ !path ||
+ typeof obj !== "object" ||
+ Object.keys(obj).length === 0 ||
+ typeof path !== "string"
+ )
+ return "";
+
+ // First split by dots that are not inside brackets
+ const parts = [];
+ let currentPart = "";
+ let inBrackets = false;
+
+ for (let i = 0; i < path.length; i++) {
+ const char = path[i];
+ if (char === "[") {
+ inBrackets = true;
+ if (currentPart) {
+ parts.push(currentPart);
+ currentPart = "";
+ }
+ currentPart += char;
+ } else if (char === "]") {
+ inBrackets = false;
+ currentPart += char;
+ parts.push(currentPart);
+ currentPart = "";
+ } else if (char === "." && !inBrackets) {
+ if (currentPart) {
+ parts.push(currentPart);
+ currentPart = "";
+ }
+ } else {
+ currentPart += char;
+ }
+ }
+
+ if (currentPart) parts.push(currentPart);
+ let current = obj;
+
+ for (const part of parts) {
+ if (current === null || typeof current !== "object") return undefined;
+
+ // Handle bracket notation
+ if (part.startsWith("[") && part.endsWith("]")) {
+ const key = part.slice(1, -1);
+ const cleanKey = key.replace(/^['"]|['"]$/g, "");
+
+ if (!isNaN(cleanKey)) {
+ if (!Array.isArray(current)) return undefined;
+ current = current[parseInt(cleanKey)];
+ } else {
+ if (!(cleanKey in current)) return undefined;
+ current = current[cleanKey];
+ }
+ } else {
+ // Handle dot notation
+ if (!(part in current)) return undefined;
+ current = current[part];
+ }
+
+ if (current === undefined || current === null) return undefined;
+ }
+
+ return typeof current === "object" ? JSON.stringify(current) : current;
+ }
+
+ /**
+ * Replaces variables in the config with their values
+ * @param {Object} config - The config to replace variables in
+ * @returns {Object} The config with variables replaced
+ */
+ replaceVariables(config) {
+ const deepReplace = (obj) => {
+ if (typeof obj === "string") {
+ return obj.replace(/\${([^}]+)}/g, (match, varName) => {
+ const value = this.getValueFromPath(this.variables, varName);
+ return value !== undefined ? value : match;
+ });
+ }
+
+ if (Array.isArray(obj)) return obj.map((item) => deepReplace(item));
+
+ if (obj && typeof obj === "object") {
+ const result = {};
+ for (const [key, value] of Object.entries(obj)) {
+ result[key] = deepReplace(value);
+ }
+ return result;
+ }
+ return obj;
+ };
+
+ return deepReplace(config);
+ }
+
+ /**
+ * Executes a single step of the flow
+ * @param {Object} step - The step to execute
+ * @returns {Promise} The result of the step
+ */
+ async executeStep(step) {
+ const config = this.replaceVariables(step.config);
+ let result;
+ // Create execution context with introspect
+ const context = {
+ introspect: this.introspect,
+ variables: this.variables,
+ logger: this.logger,
+ aibitat: this.aibitat,
+ };
+
+ switch (step.type) {
+ case FLOW_TYPES.START.type:
+ // For start blocks, we just initialize variables if they're not already set
+ if (config.variables) {
+ config.variables.forEach((v) => {
+ if (v.name && !this.variables[v.name]) {
+ this.variables[v.name] = v.value || "";
+ }
+ });
+ }
+ result = this.variables;
+ break;
+ case FLOW_TYPES.API_CALL.type:
+ result = await executeApiCall(config, context);
+ break;
+ case FLOW_TYPES.LLM_INSTRUCTION.type:
+ result = await executeLLMInstruction(config, context);
+ break;
+ case FLOW_TYPES.WEB_SCRAPING.type:
+ result = await executeWebScraping(config, context);
+ break;
+ default:
+ throw new Error(`Unknown flow type: ${step.type}`);
+ }
+
+ // Store result in variable if specified
+ if (config.resultVariable || config.responseVariable) {
+ const varName = config.resultVariable || config.responseVariable;
+ this.variables[varName] = result;
+ }
+
+ // If directOutput is true, mark this result for direct output
+ if (config.directOutput) result = { directOutput: true, result };
+ return result;
+ }
+
+ /**
+ * Execute entire flow
+ * @param {Object} flow - The flow to execute
+ * @param {Object} initialVariables - Initial variables for the flow
+ * @param {Object} aibitat - The aibitat instance from the agent handler
+ */
+ async executeFlow(flow, initialVariables = {}, aibitat) {
+ await Telemetry.sendTelemetry("agent_flow_execution_started");
+
+ // Initialize variables with both initial values and any passed-in values
+ this.variables = {
+ ...(
+ flow.config.steps.find((s) => s.type === "start")?.config?.variables ||
+ []
+ ).reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {}),
+ ...initialVariables, // This will override any default values with passed-in values
+ };
+
+ this.aibitat = aibitat;
+ this.attachLogging(aibitat?.introspect, aibitat?.handlerProps?.log);
+ const results = [];
+ let directOutputResult = null;
+
+ for (const step of flow.config.steps) {
+ try {
+ const result = await this.executeStep(step);
+
+ // If the step has directOutput, stop processing and return the result
+ // so that no other steps are executed or processed
+ if (result?.directOutput) {
+ directOutputResult = result.result;
+ break;
+ }
+
+ results.push({ success: true, result });
+ } catch (error) {
+ results.push({ success: false, error: error.message });
+ break;
+ }
+ }
+
+ return {
+ success: results.every((r) => r.success),
+ results,
+ variables: this.variables,
+ directOutput: directOutputResult,
+ };
+ }
+}
+
+module.exports = {
+ FlowExecutor,
+ FLOW_TYPES,
+};
diff --git a/server/utils/agentFlows/executors/api-call.js b/server/utils/agentFlows/executors/api-call.js
new file mode 100644
index 0000000000000000000000000000000000000000..6d0602438a395da6fa1d1c85806df8c2be07c771
--- /dev/null
+++ b/server/utils/agentFlows/executors/api-call.js
@@ -0,0 +1,60 @@
+const { safeJsonParse } = require("../../http");
+
+/**
+ * Execute an API call flow step
+ * @param {Object} config Flow step configuration
+ * @param {Object} context Execution context with introspect function
+ * @returns {Promise} Response data
+ */
+async function executeApiCall(config, context) {
+ const { url, method, headers = [], body, bodyType, formData } = config;
+ const { introspect, logger } = context;
+ logger(`\x1b[43m[AgentFlowToolExecutor]\x1b[0m - executing API Call block`);
+ introspect(`Making ${method} request to external API...`);
+
+ const requestConfig = {
+ method,
+ headers: headers.reduce((acc, h) => ({ ...acc, [h.key]: h.value }), {}),
+ };
+
+ if (["POST", "PUT", "PATCH"].includes(method)) {
+ if (bodyType === "form") {
+ const formDataObj = new URLSearchParams();
+ formData.forEach(({ key, value }) => formDataObj.append(key, value));
+ requestConfig.body = formDataObj.toString();
+ requestConfig.headers["Content-Type"] =
+ "application/x-www-form-urlencoded";
+ } else if (bodyType === "json") {
+ const parsedBody = safeJsonParse(body, null);
+ if (parsedBody !== null) {
+ requestConfig.body = JSON.stringify(parsedBody);
+ }
+ requestConfig.headers["Content-Type"] = "application/json";
+ } else if (bodyType === "text") {
+ requestConfig.body = String(body);
+ } else {
+ requestConfig.body = body;
+ }
+ }
+
+ try {
+ introspect(`Sending body to ${url}: ${requestConfig?.body || "No body"}`);
+ const response = await fetch(url, requestConfig);
+ if (!response.ok) {
+ introspect(`Request failed with status ${response.status}`);
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ introspect(`API call completed`);
+ return await response
+ .text()
+ .then((text) =>
+ safeJsonParse(text, "Failed to parse output from API call block")
+ );
+ } catch (error) {
+ console.error(error);
+ throw new Error(`API Call failed: ${error.message}`);
+ }
+}
+
+module.exports = executeApiCall;
diff --git a/server/utils/agentFlows/executors/llm-instruction.js b/server/utils/agentFlows/executors/llm-instruction.js
new file mode 100644
index 0000000000000000000000000000000000000000..4c4b0d3b6fab37c2dd1f74adf29c44b9af4ea8dc
--- /dev/null
+++ b/server/utils/agentFlows/executors/llm-instruction.js
@@ -0,0 +1,43 @@
+/**
+ * Execute an LLM instruction flow step
+ * @param {Object} config Flow step configuration
+ * @param {{introspect: Function, logger: Function}} context Execution context with introspect function
+ * @returns {Promise} Processed result
+ */
+async function executeLLMInstruction(config, context) {
+ const { instruction, resultVariable } = config;
+ const { introspect, logger, aibitat } = context;
+ logger(
+ `\x1b[43m[AgentFlowToolExecutor]\x1b[0m - executing LLM Instruction block`
+ );
+ introspect(`Processing data with LLM instruction...`);
+
+ try {
+ logger(
+ `Sending request to LLM (${aibitat.defaultProvider.provider}::${aibitat.defaultProvider.model})`
+ );
+ introspect(`Sending request to LLM...`);
+
+ // Ensure the input is a string since we are sending it to the LLM direct as a message
+ let input = instruction;
+ if (typeof input === "object") input = JSON.stringify(input);
+ if (typeof input !== "string") input = String(input);
+
+ const provider = aibitat.getProviderForConfig(aibitat.defaultProvider);
+ const completion = await provider.complete([
+ {
+ role: "user",
+ content: input,
+ },
+ ]);
+
+ introspect(`Successfully received LLM response`);
+ if (resultVariable) config.resultVariable = resultVariable;
+ return completion.result;
+ } catch (error) {
+ logger(`LLM processing failed: ${error.message}`, error);
+ throw new Error(`LLM processing failed: ${error.message}`);
+ }
+}
+
+module.exports = executeLLMInstruction;
diff --git a/server/utils/agentFlows/executors/web-scraping.js b/server/utils/agentFlows/executors/web-scraping.js
new file mode 100644
index 0000000000000000000000000000000000000000..e10770f82e9606e7234df88469c1e22d73209de1
--- /dev/null
+++ b/server/utils/agentFlows/executors/web-scraping.js
@@ -0,0 +1,110 @@
+/**
+ * Execute a web scraping flow step
+ * @param {Object} config Flow step configuration
+ * @param {Object} context Execution context with introspect function
+ * @returns {Promise} Scraped content
+ */
+async function executeWebScraping(config, context) {
+ const { CollectorApi } = require("../../collectorApi");
+ const { TokenManager } = require("../../helpers/tiktoken");
+ const Provider = require("../../agents/aibitat/providers/ai-provider");
+ const { summarizeContent } = require("../../agents/aibitat/utils/summarize");
+
+ const { url, captureAs = "text", enableSummarization = true } = config;
+ const { introspect, logger, aibitat } = context;
+ logger(
+ `\x1b[43m[AgentFlowToolExecutor]\x1b[0m - executing Web Scraping block`
+ );
+
+ if (!url) {
+ throw new Error("URL is required for web scraping");
+ }
+
+ const captureMode = captureAs === "querySelector" ? "html" : captureAs;
+ introspect(`Scraping the content of ${url} as ${captureAs}`);
+ const { success, content } = await new CollectorApi()
+ .getLinkContent(url, captureMode)
+ .then((res) => {
+ if (captureAs !== "querySelector") return res;
+ return parseHTMLwithSelector(res.content, config.querySelector, context);
+ });
+
+ if (!success) {
+ introspect(`Could not scrape ${url}. Cannot use this page's content.`);
+ throw new Error("URL could not be scraped and no content was found.");
+ }
+
+ introspect(`Successfully scraped content from ${url}`);
+ if (!content || content?.length === 0) {
+ introspect("There was no content to be collected or read.");
+ throw new Error("There was no content to be collected or read.");
+ }
+
+ if (!enableSummarization) {
+ logger(`Returning raw content as summarization is disabled`);
+ return content;
+ }
+
+ const tokenCount = new TokenManager(
+ aibitat.defaultProvider.model
+ ).countFromString(content);
+ const contextLimit = Provider.contextLimit(
+ aibitat.defaultProvider.provider,
+ aibitat.defaultProvider.model
+ );
+
+ if (tokenCount < contextLimit) {
+ logger(
+ `Content within token limit (${tokenCount}/${contextLimit}). Returning raw content.`
+ );
+ return content;
+ }
+
+ introspect(
+ `This page's content is way too long (${tokenCount} tokens). I will summarize it right now.`
+ );
+ const summary = await summarizeContent({
+ provider: aibitat.defaultProvider.provider,
+ model: aibitat.defaultProvider.model,
+ content,
+ });
+
+ introspect(`Successfully summarized content`);
+ return summary;
+}
+
+/**
+ * Parse HTML with a CSS selector
+ * @param {string} html - The HTML to parse
+ * @param {string|null} selector - The CSS selector to use (as text string)
+ * @param {{introspect: Function}} context - The context object
+ * @returns {Object} The parsed content
+ */
+function parseHTMLwithSelector(html, selector = null, context) {
+ if (!selector || selector.length === 0) {
+ context.introspect("No selector provided. Returning the entire HTML.");
+ return { success: true, content: html };
+ }
+
+ const Cheerio = require("cheerio");
+ const $ = Cheerio.load(html);
+ const selectedElements = $(selector);
+
+ let content;
+ if (selectedElements.length === 0) {
+ return { success: false, content: null };
+ } else if (selectedElements.length === 1) {
+ content = selectedElements.html();
+ } else {
+ context.introspect(
+ `Found ${selectedElements.length} elements matching selector: ${selector}`
+ );
+ content = selectedElements
+ .map((_, element) => $(element).html())
+ .get()
+ .join("\n");
+ }
+ return { success: true, content };
+}
+
+module.exports = executeWebScraping;
diff --git a/server/utils/agentFlows/flowTypes.js b/server/utils/agentFlows/flowTypes.js
new file mode 100644
index 0000000000000000000000000000000000000000..0572bd1b32c577fbfa00cabdec3960dcad367b2d
--- /dev/null
+++ b/server/utils/agentFlows/flowTypes.js
@@ -0,0 +1,85 @@
+const FLOW_TYPES = {
+ START: {
+ type: "start",
+ description: "Initialize flow variables",
+ parameters: {
+ variables: {
+ type: "array",
+ description: "List of variables to initialize",
+ },
+ },
+ },
+ API_CALL: {
+ type: "apiCall",
+ description: "Make an HTTP request to an API endpoint",
+ parameters: {
+ url: { type: "string", description: "The URL to make the request to" },
+ method: { type: "string", description: "HTTP method (GET, POST, etc.)" },
+ headers: {
+ type: "array",
+ description: "Request headers as key-value pairs",
+ },
+ bodyType: {
+ type: "string",
+ description: "Type of request body (json, form)",
+ },
+ body: {
+ type: "string",
+ description:
+ "Request body content. If body type is json, always return a valid json object. If body type is form, always return a valid form data object.",
+ },
+ formData: { type: "array", description: "Form data as key-value pairs" },
+ responseVariable: {
+ type: "string",
+ description: "Variable to store the response",
+ },
+ directOutput: {
+ type: "boolean",
+ description:
+ "Whether to return the response directly to the user without LLM processing",
+ },
+ },
+ examples: [
+ {
+ url: "https://api.example.com/data",
+ method: "GET",
+ headers: [{ key: "Authorization", value: "Bearer 1234567890" }],
+ },
+ ],
+ },
+ LLM_INSTRUCTION: {
+ type: "llmInstruction",
+ description: "Process data using LLM instructions",
+ parameters: {
+ instruction: {
+ type: "string",
+ description: "The instruction for the LLM to follow",
+ },
+ resultVariable: {
+ type: "string",
+ description: "Variable to store the processed result",
+ },
+ },
+ },
+ WEB_SCRAPING: {
+ type: "webScraping",
+ description: "Scrape content from a webpage",
+ parameters: {
+ url: {
+ type: "string",
+ description: "The URL of the webpage to scrape",
+ },
+ resultVariable: {
+ type: "string",
+ description: "Variable to store the scraped content",
+ },
+ directOutput: {
+ type: "boolean",
+ description:
+ "Whether to return the scraped content directly to the user without LLM processing",
+ },
+ },
+ },
+};
+
+module.exports.FLOW_TYPES = FLOW_TYPES;
diff --git a/server/utils/agentFlows/index.js b/server/utils/agentFlows/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e4944d2c8667c6f27b18b8ba093947669dccc2b6
--- /dev/null
+++ b/server/utils/agentFlows/index.js
@@ -0,0 +1,262 @@
+const fs = require("fs");
+const path = require("path");
+const { v4: uuidv4 } = require("uuid");
+const { FlowExecutor, FLOW_TYPES } = require("./executor");
+const { normalizePath } = require("../files");
+const { safeJsonParse } = require("../http");
+
+/**
+ * @typedef {Object} LoadedFlow
+ * @property {string} name - The name of the flow
+ * @property {string} uuid - The UUID of the flow
+ * @property {Object} config - The flow configuration details
+ * @property {string} config.description - The description of the flow
+ * @property {Array<{type: string, config: Object, [key: string]: any}>} config.steps - The steps of the flow. Each step has at least a type and config
+ */
+
+class AgentFlows {
+ static flowsDir = process.env.STORAGE_DIR
+ ? path.join(process.env.STORAGE_DIR, "plugins", "agent-flows")
+ : path.join(process.cwd(), "storage", "plugins", "agent-flows");
+
+ constructor() {}
+
+ /**
+ * Ensure flows directory exists
+ * @returns {Boolean} True if directory exists, false otherwise
+ */
+ static createOrCheckFlowsDir() {
+ try {
+ if (fs.existsSync(AgentFlows.flowsDir)) return true;
+ fs.mkdirSync(AgentFlows.flowsDir, { recursive: true });
+ return true;
+ } catch (error) {
+ console.error("Failed to create flows directory:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Helper to get all flow files with their contents
+ * @returns {Object} Map of flow UUID to flow config
+ */
+ static getAllFlows() {
+ AgentFlows.createOrCheckFlowsDir();
+ const files = fs.readdirSync(AgentFlows.flowsDir);
+ const flows = {};
+
+ for (const file of files) {
+ if (!file.endsWith(".json")) continue;
+ try {
+ const filePath = path.join(AgentFlows.flowsDir, file);
+ const content = fs.readFileSync(normalizePath(filePath), "utf8");
+ const config = JSON.parse(content);
+ const id = file.replace(".json", "");
+ flows[id] = config;
+ } catch (error) {
+ console.error(`Error reading flow file ${file}:`, error);
+ }
+ }
+
+ return flows;
+ }
+
+ /**
+ * Load a flow configuration by UUID
+ * @param {string} uuid - The UUID of the flow to load
+ * @returns {LoadedFlow|null} Flow configuration or null if not found
+ */
+ static loadFlow(uuid) {
+ try {
+ const flowJsonPath = normalizePath(
+ path.join(AgentFlows.flowsDir, `${uuid}.json`)
+ );
+ if (!uuid || !fs.existsSync(flowJsonPath)) return null;
+ const flow = safeJsonParse(fs.readFileSync(flowJsonPath, "utf8"), null);
+ if (!flow) return null;
+
+ return {
+ name: flow.name,
+ uuid,
+ config: flow,
+ };
+ } catch (error) {
+ console.error("Failed to load flow:", error);
+ return null;
+ }
+ }
+
+ /**
+ * Save a flow configuration
+ * @param {string} name - The name of the flow
+ * @param {Object} config - The flow configuration
+ * @param {string|null} uuid - Optional UUID for the flow
+ * @returns {Object} Result of the save operation
+ */
+ static saveFlow(name, config, uuid = null) {
+ try {
+ AgentFlows.createOrCheckFlowsDir();
+
+ if (!uuid) uuid = uuidv4();
+ const normalizedUuid = normalizePath(`${uuid}.json`);
+ const filePath = path.join(AgentFlows.flowsDir, normalizedUuid);
+
+ // Prevent saving flows with unsupported blocks or importing
+ // flows with unsupported blocks (eg: file writing or code execution on Desktop importing to Docker)
+ const supportedFlowTypes = Object.values(FLOW_TYPES).map(
+ (definition) => definition.type
+ );
+ const supportsAllBlocks = config.steps.every((step) =>
+ supportedFlowTypes.includes(step.type)
+ );
+ if (!supportsAllBlocks)
+ throw new Error(
+ "This flow includes unsupported blocks. They may not be supported by your version of AnythingLLM or are not available on this platform."
+ );
+
+ fs.writeFileSync(filePath, JSON.stringify({ ...config, name }, null, 2));
+ return { success: true, uuid };
+ } catch (error) {
+ console.error("Failed to save flow:", error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * List all available flows
+ * @returns {Array} Array of flow summaries
+ */
+ static listFlows() {
+ try {
+ const flows = AgentFlows.getAllFlows();
+ return Object.entries(flows).map(([uuid, flow]) => ({
+ name: flow.name,
+ uuid,
+ description: flow.description,
+ active: flow.active !== false,
+ }));
+ } catch (error) {
+ console.error("Failed to list flows:", error);
+ return [];
+ }
+ }
+
+ /**
+ * Delete a flow by UUID
+ * @param {string} uuid - The UUID of the flow to delete
+ * @returns {Object} Result of the delete operation
+ */
+ static deleteFlow(uuid) {
+ try {
+ const filePath = normalizePath(
+ path.join(AgentFlows.flowsDir, `${uuid}.json`)
+ );
+ if (!fs.existsSync(filePath)) throw new Error(`Flow ${uuid} not found`);
+ fs.rmSync(filePath);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to delete flow:", error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * Execute a flow by UUID
+ * @param {string} uuid - The UUID of the flow to execute
+ * @param {Object} variables - Initial variables for the flow
+ * @param {Object} aibitat - The aibitat instance from the agent handler
+ * @returns {Promise} Result of flow execution
+ */
+ static async executeFlow(uuid, variables = {}, aibitat = null) {
+ const flow = AgentFlows.loadFlow(uuid);
+ if (!flow) throw new Error(`Flow ${uuid} not found`);
+ const flowExecutor = new FlowExecutor();
+ return await flowExecutor.executeFlow(flow, variables, aibitat);
+ }
+
+ /**
+ * Get all active flows as plugins that can be loaded into the agent
+ * @returns {string[]} Array of flow names in @@flow_{uuid} format
+ */
+ static activeFlowPlugins() {
+ const flows = AgentFlows.getAllFlows();
+ return Object.entries(flows)
+ .filter(([_, flow]) => flow.active !== false)
+ .map(([uuid]) => `@@flow_${uuid}`);
+ }
+
+ /**
+ * Load a flow plugin by its UUID
+ * @param {string} uuid - The UUID of the flow to load
+ * @returns {Object|null} Plugin configuration or null if not found
+ */
+ static loadFlowPlugin(uuid) {
+ const flow = AgentFlows.loadFlow(uuid);
+ if (!flow) return null;
+
+ const startBlock = flow.config.steps?.find((s) => s.type === "start");
+ const variables = startBlock?.config?.variables || [];
+
+ return {
+ name: `flow_${uuid}`,
+ description: `Execute agent flow: ${flow.name}`,
+ plugin: (_runtimeArgs = {}) => ({
+ name: `flow_${uuid}`,
+ description:
+ flow.config.description || `Execute agent flow: ${flow.name}`,
+ setup: (aibitat) => {
+ aibitat.function({
+ name: `flow_${uuid}`,
+ description:
+ flow.config.description || `Execute agent flow: ${flow.name}`,
+ parameters: {
+ type: "object",
+ properties: variables.reduce((acc, v) => {
+ if (v.name) {
+ acc[v.name] = {
+ type: "string",
+ description:
+ v.description || `Value for variable ${v.name}`,
+ };
+ }
+ return acc;
+ }, {}),
+ },
+ handler: async (args) => {
+ aibitat.introspect(`Executing flow: ${flow.name}`);
+ const result = await AgentFlows.executeFlow(uuid, args, aibitat);
+ if (!result.success) {
+ aibitat.introspect(
+ `Flow failed: ${result.results[0]?.error || "Unknown error"}`
+ );
+ return `Flow execution failed: ${result.results[0]?.error || "Unknown error"}`;
+ }
+ aibitat.introspect(`${flow.name} completed successfully`);
+
+ // If the flow result has directOutput, return it
+ // as the aibitat result so that no other processing is done
+ if (!!result.directOutput) {
+ aibitat.skipHandleExecution = true;
+ return AgentFlows.stringifyResult(result.directOutput);
+ }
+
+ return AgentFlows.stringifyResult(result);
+ },
+ });
+ },
+ }),
+ flowName: flow.name,
+ };
+ }
+
+ /**
+ * Stringify the result of a flow execution or return the input as is
+ * @param {Object|string} input - The result to stringify
+ * @returns {string} The stringified result
+ */
+ static stringifyResult(input) {
+ return typeof input === "object" ? JSON.stringify(input) : String(input);
+ }
+}
+
+module.exports.AgentFlows = AgentFlows;
diff --git a/server/utils/agents/aibitat/error.js b/server/utils/agents/aibitat/error.js
new file mode 100644
index 0000000000000000000000000000000000000000..223f3351e8008fb2831e5d1dca7d691be0c50cb5
--- /dev/null
+++ b/server/utils/agents/aibitat/error.js
@@ -0,0 +1,18 @@
+class AIbitatError extends Error {}
+
+class APIError extends AIbitatError {
+ constructor(message) {
+ super(message);
+ }
+}
+
+/**
+ * The error when the AI provider returns an error that should be treated as something
+ * that should be retried.
+ */
+class RetryError extends APIError {}
+
+module.exports = {
+ APIError,
+ RetryError,
+};
diff --git a/server/utils/agents/aibitat/example/.gitignore b/server/utils/agents/aibitat/example/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..4b0412c79c3bc3817e8673c1710110d3837b4157
--- /dev/null
+++ b/server/utils/agents/aibitat/example/.gitignore
@@ -0,0 +1 @@
+history/
\ No newline at end of file
diff --git a/server/utils/agents/aibitat/example/beginner-chat.js b/server/utils/agents/aibitat/example/beginner-chat.js
new file mode 100644
index 0000000000000000000000000000000000000000..d81c2b710f289271fa1d0a1f6cd813838ec114d3
--- /dev/null
+++ b/server/utils/agents/aibitat/example/beginner-chat.js
@@ -0,0 +1,56 @@
+// You must execute this example from within the example folder.
+const AIbitat = require("../index.js");
+const { cli } = require("../plugins/cli.js");
+const { NodeHtmlMarkdown } = require("node-html-markdown");
+require("dotenv").config({ path: `../../../../.env.development` });
+
+const Agent = {
+ HUMAN: "🧑",
+ AI: "🤖",
+};
+
+const aibitat = new AIbitat({
+ provider: "openai",
+ model: "gpt-4o",
+})
+ .use(cli.plugin())
+ .function({
+ name: "aibitat-documentations",
+ description: "The documentation about aibitat AI project.",
+ parameters: {
+ type: "object",
+ properties: {},
+ },
+ handler: async () => {
+ return await fetch(
+ "https://raw.githubusercontent.com/wladiston/aibitat/main/README.md"
+ )
+ .then((res) => res.text())
+ .then((html) => NodeHtmlMarkdown.translate(html))
+ .catch((e) => {
+ console.error(e.message);
+ return "FAILED TO FETCH";
+ });
+ },
+ })
+ .agent(Agent.HUMAN, {
+ interrupt: "ALWAYS",
+ role: "You are a human assistant.",
+ })
+ .agent(Agent.AI, {
+ functions: ["aibitat-documentations"],
+ });
+
+async function main() {
+ if (!process.env.OPEN_AI_KEY)
+ throw new Error(
+ "This example requires a valid OPEN_AI_KEY in the env.development file"
+ );
+ await aibitat.start({
+ from: Agent.HUMAN,
+ to: Agent.AI,
+ content: `Please, talk about the documentation of AIbitat.`,
+ });
+}
+
+main();
diff --git a/server/utils/agents/aibitat/example/blog-post-coding.js b/server/utils/agents/aibitat/example/blog-post-coding.js
new file mode 100644
index 0000000000000000000000000000000000000000..1764d498bc1ff6d4cc494bc0bd11fae4b61e490f
--- /dev/null
+++ b/server/utils/agents/aibitat/example/blog-post-coding.js
@@ -0,0 +1,55 @@
+const AIbitat = require("../index.js");
+const {
+ cli,
+ webBrowsing,
+ fileHistory,
+ webScraping,
+} = require("../plugins/index.js");
+require("dotenv").config({ path: `../../../../.env.development` });
+
+const aibitat = new AIbitat({
+ model: "gpt-4o",
+})
+ .use(cli.plugin())
+ .use(fileHistory.plugin())
+ .use(webBrowsing.plugin()) // Does not have introspect so will fail.
+ .use(webScraping.plugin())
+ .agent("researcher", {
+ role: `You are a Researcher. Conduct thorough research to gather all necessary information about the topic
+ you are writing about. Collect data, facts, and statistics. Analyze competitor blogs for insights.
+ Provide accurate and up-to-date information that supports the blog post's content to @copywriter.`,
+ functions: ["web-browsing"],
+ })
+ .agent("copywriter", {
+ role: `You are a Copywriter. Interpret the draft as general idea and write the full blog post using markdown,
+ ensuring it is tailored to the target audience's preferences, interests, and demographics. Apply genre-specific
+ writing techniques relevant to the author's genre. Add code examples when needed. Code must be written in
+ Typescript. Always mention references. Revisit and edit the post for clarity, coherence, and
+ correctness based on the feedback provided. Ask for feedbacks to the channel when you are done`,
+ })
+ .agent("pm", {
+ role: `You are a Project Manager. Coordinate the project, ensure tasks are completed on time and within budget.
+ Communicate with team members and stakeholders.`,
+ interrupt: "ALWAYS",
+ })
+ .channel("content-team", ["researcher", "copywriter", "pm"]);
+
+async function main() {
+ if (!process.env.OPEN_AI_KEY)
+ throw new Error(
+ "This example requires a valid OPEN_AI_KEY in the env.development file"
+ );
+ await aibitat.start({
+ from: "pm",
+ to: "content-team",
+ content: `We have got this draft of the new blog post, let us start working on it.
+ --- BEGIN DRAFT OF POST ---
+
+ Maui is a beautiful island in the state of Hawaii and is world-renowned for its whale watching season. Here are 2 additional things to do in Maui, HI:
+
+ --- END DRAFT OF POST ---
+ `,
+ });
+}
+
+main();
diff --git a/server/utils/agents/aibitat/example/websocket/index.html b/server/utils/agents/aibitat/example/websocket/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..2fbb56c9345a9746d56eed329e51927b6fe81816
--- /dev/null
+++ b/server/utils/agents/aibitat/example/websocket/index.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+ Open websocket connection chat
+
+
+
diff --git a/server/utils/agents/aibitat/example/websocket/websock-branding-collab.js b/server/utils/agents/aibitat/example/websocket/websock-branding-collab.js
new file mode 100644
index 0000000000000000000000000000000000000000..809aafd3c802fa69366b924a57390f90561819dd
--- /dev/null
+++ b/server/utils/agents/aibitat/example/websocket/websock-branding-collab.js
@@ -0,0 +1,100 @@
+// You can only run this example from within the websocket/ directory.
+// NODE_ENV=development node websock-branding-collab.js
+// Scraping is enabled, but search requires AGENT_GSE_* keys.
+
+const express = require("express");
+const chalk = require("chalk");
+const AIbitat = require("../../index.js");
+const {
+ websocket,
+ webBrowsing,
+ webScraping,
+} = require("../../plugins/index.js");
+const path = require("path");
+const port = 3000;
+const app = express();
+require("@mintplex-labs/express-ws").default(app); // load WebSockets in non-SSL mode.
+require("dotenv").config({ path: `../../../../../.env.development` });
+
+// Debugging echo function if this is working for you.
+// app.ws('/echo', function (ws, req) {
+// ws.on('message', function (msg) {
+// ws.send(msg);
+// });
+// });
+
+// Set up WSS sockets for listening.
+app.ws("/ws", function (ws, _response) {
+ try {
+ ws.on("message", function (msg) {
+ if (ws?.handleFeedback) ws.handleFeedback(msg);
+ });
+
+ ws.on("close", function () {
+ console.log("Socket killed");
+ return;
+ });
+
+ console.log("Socket online and waiting...");
+ runAIbitat(ws).catch((error) => {
+ ws.send(
+ JSON.stringify({
+ from: "AI",
+ to: "HUMAN",
+ content: error.message,
+ })
+ );
+ });
+ } catch (error) {}
+});
+
+app.all("*", function (_, response) {
+ response.sendFile(path.join(__dirname, "index.html"));
+});
+
+app.listen(port, () => {
+ console.log(`Testing HTTP/WSS server listening at http://localhost:${port}`);
+});
+
+async function runAIbitat(socket) {
+ console.log(chalk.blue("Booting AIbitat class & starting agent(s)"));
+
+ const aibitat = new AIbitat({
+ provider: "openai",
+ model: "gpt-4",
+ })
+ .use(websocket.plugin({ socket }))
+ .use(webBrowsing.plugin())
+ .use(webScraping.plugin())
+ .agent("creativeDirector", {
+ role: `You are a Creative Director. Your role is overseeing the entire branding project, ensuring
+ the client's brief is met, and maintaining consistency across all brand elements, developing the
+ brand strategy, guiding the visual and conceptual direction, and providing overall creative leadership.`,
+ })
+ .agent("marketResearcher", {
+ role: `You do competitive market analysis via searching on the internet and learning about
+ comparative products and services. You can search by using keywords and phrases that you think will lead
+ to competitor research that can help find the unique angle and market of the idea.`,
+ functions: ["web-browsing"],
+ })
+ .agent("PM", {
+ role: `You are the Project Coordinator. Your role is overseeing the project's progress, timeline,
+ and budget. Ensure effective communication and coordination among team members, client, and stakeholders.
+ Your tasks include planning and scheduling project milestones, tracking tasks, and managing any
+ risks or issues that arise.`,
+ interrupt: "ALWAYS",
+ })
+ .channel("#branding ", [
+ "creativeDirector",
+ "marketResearcher",
+ "PM",
+ ]);
+
+ await aibitat.start({
+ from: "PM",
+ to: "#branding ",
+ content: `I have an idea for a muslim focused meetup called Chai & Vibes.
+ I want to focus on professionals that are muslim and are in their 18-30 year old range who live in big cities.
+ Does anything like this exist? How can we differentiate?`,
+ });
+}
diff --git a/server/utils/agents/aibitat/example/websocket/websock-multi-turn-chat.js b/server/utils/agents/aibitat/example/websocket/websock-multi-turn-chat.js
new file mode 100644
index 0000000000000000000000000000000000000000..6c58709bbbb88dc389606a27d5a37f4feec80452
--- /dev/null
+++ b/server/utils/agents/aibitat/example/websocket/websock-multi-turn-chat.js
@@ -0,0 +1,91 @@
+// You can only run this example from within the websocket/ directory.
+// NODE_ENV=development node websock-multi-turn-chat.js
+// Scraping is enabled, but search requires AGENT_GSE_* keys.
+
+const express = require("express");
+const chalk = require("chalk");
+const AIbitat = require("../../index.js");
+const {
+ websocket,
+ webBrowsing,
+ webScraping,
+} = require("../../plugins/index.js");
+const path = require("path");
+const port = 3000;
+const app = express();
+require("@mintplex-labs/express-ws").default(app); // load WebSockets in non-SSL mode.
+require("dotenv").config({ path: `../../../../../.env.development` });
+
+// Debugging echo function if this is working for you.
+// app.ws('/echo', function (ws, req) {
+// ws.on('message', function (msg) {
+// ws.send(msg);
+// });
+// });
+
+// Set up WSS sockets for listening.
+app.ws("/ws", function (ws, _response) {
+ try {
+ ws.on("message", function (msg) {
+ if (ws?.handleFeedback) ws.handleFeedback(msg);
+ });
+
+ ws.on("close", function () {
+ console.log("Socket killed");
+ return;
+ });
+
+ console.log("Socket online and waiting...");
+ runAIbitat(ws).catch((error) => {
+ ws.send(
+ JSON.stringify({
+ from: Agent.AI,
+ to: Agent.HUMAN,
+ content: error.message,
+ })
+ );
+ });
+ } catch (error) {}
+});
+
+app.all("*", function (_, response) {
+ response.sendFile(path.join(__dirname, "index.html"));
+});
+
+app.listen(port, () => {
+ console.log(`Testing HTTP/WSS server listening at http://localhost:${port}`);
+});
+
+const Agent = {
+ HUMAN: "🧑",
+ AI: "🤖",
+};
+
+async function runAIbitat(socket) {
+ if (!process.env.OPEN_AI_KEY)
+ throw new Error(
+ "This example requires a valid OPEN_AI_KEY in the env.development file"
+ );
+ console.log(chalk.blue("Booting AIbitat class & starting agent(s)"));
+ const aibitat = new AIbitat({
+ provider: "openai",
+ model: "gpt-4o",
+ })
+ .use(websocket.plugin({ socket }))
+ .use(webBrowsing.plugin())
+ .use(webScraping.plugin())
+ .agent(Agent.HUMAN, {
+ interrupt: "ALWAYS",
+ role: "You are a human assistant.",
+ })
+ .agent(Agent.AI, {
+ role: "You are a helpful ai assistant who likes to chat with the user who an also browse the web for questions it does not know or have real-time access to.",
+ functions: ["web-browsing"],
+ });
+
+ await aibitat.start({
+ from: Agent.HUMAN,
+ to: Agent.AI,
+ content: `How are you doing today?`,
+ });
+}
diff --git a/server/utils/agents/aibitat/index.js b/server/utils/agents/aibitat/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..683850dfcb941136aba06f83bdaffbe983961229
--- /dev/null
+++ b/server/utils/agents/aibitat/index.js
@@ -0,0 +1,853 @@
+const { EventEmitter } = require("events");
+const { APIError } = require("./error.js");
+const Providers = require("./providers/index.js");
+const { Telemetry } = require("../../../models/telemetry.js");
+
+/**
+ * AIbitat is a class that manages the conversation between agents.
+ * It is designed to solve a task with LLM.
+ *
+ * Guiding the chat through a graph of agents.
+ */
+class AIbitat {
+ emitter = new EventEmitter();
+
+ /**
+ * Temporary flag to skip the handleExecution function
+ * This is used to return the result of a flow execution directly to the chat
+ * without going through the handleExecution function (resulting in more LLM processing)
+ *
+ * Setting Skip execution to true will prevent any further tool calls from being executed.
+ * This is useful for flow executions that need to return a result directly to the chat but
+ * can also prevent tool-call chaining.
+ *
+ * @type {boolean}
+ */
+ skipHandleExecution = false;
+
+ provider = null;
+ defaultProvider = null;
+ defaultInterrupt;
+ maxRounds;
+ _chats;
+
+ agents = new Map();
+ channels = new Map();
+ functions = new Map();
+
+ constructor(props = {}) {
+ const {
+ chats = [],
+ interrupt = "NEVER",
+ maxRounds = 100,
+ provider = "openai",
+ handlerProps = {}, // Inherited props we can spread so aibitat can access.
+ ...rest
+ } = props;
+ this._chats = chats;
+ this.defaultInterrupt = interrupt;
+ this.maxRounds = maxRounds;
+ this.handlerProps = handlerProps;
+
+ this.defaultProvider = {
+ provider,
+ ...rest,
+ };
+ this.provider = this.defaultProvider.provider;
+ this.model = this.defaultProvider.model;
+ }
+
+ /**
+ * Get the chat history between agents and channels.
+ */
+ get chats() {
+ return this._chats;
+ }
+
+ /**
+ * Install a plugin.
+ */
+ use(plugin) {
+ plugin.setup(this);
+ return this;
+ }
+
+ /**
+ * Add a new agent to the AIbitat.
+ *
+ * @param name
+ * @param config
+ * @returns
+ */
+ agent(name = "", config = {}) {
+ this.agents.set(name, config);
+ return this;
+ }
+
+ /**
+ * Add a new channel to the AIbitat.
+ *
+ * @param name
+ * @param members
+ * @param config
+ * @returns
+ */
+ channel(name = "", members = [""], config = {}) {
+ this.channels.set(name, {
+ members,
+ ...config,
+ });
+ return this;
+ }
+
+ /**
+ * Get the specific agent configuration.
+ *
+ * @param agent The name of the agent.
+ * @throws When the agent configuration is not found.
+ * @returns The agent configuration.
+ */
+ getAgentConfig(agent = "") {
+ const config = this.agents.get(agent);
+ if (!config) {
+ throw new Error(`Agent configuration "${agent}" not found`);
+ }
+ return {
+ role: "You are a helpful AI assistant.",
+ // role: `You are a helpful AI assistant.
+ // Solve tasks using your coding and language skills.
+ // In the following cases, suggest typescript code (in a typescript coding block) or shell script (in a sh coding block) for the user to execute.
+ // 1. When you need to collect info, use the code to output the info you need, for example, browse or search the web, download/read a file, print the content of a webpage or a file, get the current date/time, check the operating system. After sufficient info is printed and the task is ready to be solved based on your language skill, you can solve the task by yourself.
+ // 2. When you need to perform some task with code, use the code to perform the task and output the result. Finish the task smartly.
+ // Solve the task step by step if you need to. If a plan is not provided, explain your plan first. Be clear which step uses code, and which step uses your language skill.
+ // When using code, you must indicate the script type in the code block. The user cannot provide any other feedback or perform any other action beyond executing the code you suggest. The user can't modify your code. So do not suggest incomplete code which requires users to modify. Don't use a code block if it's not intended to be executed by the user.
+ // If you want the user to save the code in a file before executing it, put # filename: inside the code block as the first line. Don't include multiple code blocks in one response. Do not ask users to copy and paste the result. Instead, use 'print' function for the output when relevant. Check the execution result returned by the user.
+ // If the result indicates there is an error, fix the error and output the code again. Suggest the full code instead of partial code or code changes. If the error can't be fixed or if the task is not solved even after the code is executed successfully, analyze the problem, revisit your assumption, collect additional info you need, and think of a different approach to try.
+ // When you find an answer, verify the answer carefully. Include verifiable evidence in your response if possible.
+ // Reply "TERMINATE" when everything is done.`,
+ ...config,
+ };
+ }
+
+ /**
+ * Get the specific channel configuration.
+ *
+ * @param channel The name of the channel.
+ * @throws When the channel configuration is not found.
+ * @returns The channel configuration.
+ */
+ getChannelConfig(channel = "") {
+ const config = this.channels.get(channel);
+ if (!config) {
+ throw new Error(`Channel configuration "${channel}" not found`);
+ }
+ return {
+ maxRounds: 10,
+ role: "",
+ ...config,
+ };
+ }
+
+ /**
+ * Get the members of a group.
+ * @throws When the group is not defined as an array in the connections.
+ * @param node The name of the group.
+ * @returns The members of the group.
+ */
+ getGroupMembers(node = "") {
+ const group = this.getChannelConfig(node);
+ return group.members;
+ }
+
+ /**
+ * Triggered when a plugin, socket, or command is aborted.
+ *
+ * @param listener
+ * @returns
+ */
+ onAbort(listener = () => null) {
+ this.emitter.on("abort", listener);
+ return this;
+ }
+
+ /**
+ * Abort the running of any plugins that may still be pending (Langchain summarize)
+ */
+ abort() {
+ this.emitter.emit("abort", null, this);
+ }
+
+ /**
+ * Triggered when a chat is terminated. After this, the chat can't be continued.
+ *
+ * @param listener
+ * @returns
+ */
+ onTerminate(listener = () => null) {
+ this.emitter.on("terminate", listener);
+ return this;
+ }
+
+ /**
+ * Terminate the chat. After this, the chat can't be continued.
+ *
+ * @param node Last node to chat with
+ */
+ terminate(node = "") {
+ this.emitter.emit("terminate", node, this);
+ }
+
+ /**
+ * Triggered when a chat is interrupted by a node.
+ *
+ * @param listener
+ * @returns
+ */
+ onInterrupt(listener = () => null) {
+ this.emitter.on("interrupt", listener);
+ return this;
+ }
+
+ /**
+ * Interruption the chat.
+ *
+ * @param route The nodes that participated in the interruption.
+ * @returns
+ */
+ interrupt(route) {
+ this._chats.push({
+ ...route,
+ state: "interrupt",
+ });
+ this.emitter.emit("interrupt", route, this);
+ }
+
+ /**
+ * Triggered when a message is added to the chat history.
+ * This can either be the first message or a reply to a message.
+ *
+ * @param listener
+ * @returns
+ */
+ onMessage(listener = (chat) => null) {
+ this.emitter.on("message", listener);
+ return this;
+ }
+
+ /**
+ * Register a new successful message in the chat history.
+ * This will trigger the `onMessage` event.
+ *
+ * @param message
+ */
+ newMessage(message) {
+ const chat = {
+ ...message,
+ state: "success",
+ };
+
+ this._chats.push(chat);
+ this.emitter.emit("message", chat, this);
+ }
+
+ /**
+ * Triggered when an error occurs during the chat.
+ *
+ * @param listener
+ * @returns
+ */
+ onError(
+ listener = (
+ /**
+ * The error that occurred.
+ *
+ * Native errors are:
+ * - `APIError`
+ * - `AuthorizationError`
+ * - `UnknownError`
+ * - `RateLimitError`
+ * - `ServerError`
+ */
+ error = null,
+ /**
+ * The message when the error occurred.
+ */
+ {}
+ ) => null
+ ) {
+ this.emitter.on("replyError", listener);
+ return this;
+ }
+
+ /**
+ * Register an error in the chat history.
+ * This will trigger the `onError` event.
+ *
+ * @param route
+ * @param error
+ */
+ newError(route, error) {
+ const chat = {
+ ...route,
+ content: error instanceof Error ? error.message : String(error),
+ state: "error",
+ };
+ this._chats.push(chat);
+ this.emitter.emit("replyError", error, chat);
+ }
+
+ /**
+ * Triggered when a chat is interrupted by a node.
+ *
+ * @param listener
+ * @returns
+ */
+ onStart(listener = (chat, aibitat) => null) {
+ this.emitter.on("start", listener);
+ return this;
+ }
+
+ /**
+ * Start a new chat.
+ *
+ * @param message The message to start the chat.
+ */
+ async start(message) {
+ // register the message in the chat history
+ this.newMessage(message);
+ this.emitter.emit("start", message, this);
+
+ // ask the node to reply
+ await this.chat({
+ to: message.from,
+ from: message.to,
+ });
+
+ return this;
+ }
+
+ /**
+ * Recursively chat between two nodes.
+ *
+ * @param route
+ * @param keepAlive Whether to keep the chat alive.
+ */
+ async chat(route, keepAlive = true) {
+ // check if the message is for a group
+ // if it is, select the next node to chat with from the group
+ // and then ask them to reply.
+ if (this.channels.get(route.from)) {
+ // select a node from the group
+ let nextNode;
+ try {
+ nextNode = await this.selectNext(route.from);
+ } catch (error) {
+ if (error instanceof APIError) {
+ return this.newError({ from: route.from, to: route.to }, error);
+ }
+ throw error;
+ }
+
+ if (!nextNode) {
+ // TODO: should it throw an error or keep the chat alive when there is no node to chat with in the group?
+ // maybe it should wrap up the chat and reply to the original node
+ // For now, it will terminate the chat
+ this.terminate(route.from);
+ return;
+ }
+
+ const nextChat = {
+ from: nextNode,
+ to: route.from,
+ };
+
+ if (this.shouldAgentInterrupt(nextNode)) {
+ this.interrupt(nextChat);
+ return;
+ }
+
+ // get chats only from the group's nodes
+ const history = this.getHistory({ to: route.from });
+ const group = this.getGroupMembers(route.from);
+ const rounds = history.filter((chat) => group.includes(chat.from)).length;
+
+ const { maxRounds } = this.getChannelConfig(route.from);
+ if (rounds >= maxRounds) {
+ this.terminate(route.to);
+ return;
+ }
+
+ await this.chat(nextChat);
+ return;
+ }
+
+ // If it's a direct message, reply to the message
+ let reply = "";
+ try {
+ reply = await this.reply(route);
+ } catch (error) {
+ if (error instanceof APIError) {
+ return this.newError({ from: route.from, to: route.to }, error);
+ }
+ throw error;
+ }
+
+ if (
+ reply === "TERMINATE" ||
+ this.hasReachedMaximumRounds(route.from, route.to)
+ ) {
+ this.terminate(route.to);
+ return;
+ }
+
+ const newChat = { to: route.from, from: route.to };
+
+ if (
+ reply === "INTERRUPT" ||
+ (this.agents.get(route.to) && this.shouldAgentInterrupt(route.to))
+ ) {
+ this.interrupt(newChat);
+ return;
+ }
+
+ if (keepAlive) {
+ // keep the chat alive by replying to the other node
+ await this.chat(newChat, true);
+ }
+ }
+
+ /**
+ * Check if the agent should interrupt the chat based on its configuration.
+ *
+ * @param agent
+ * @returns {boolean} Whether the agent should interrupt the chat.
+ */
+ shouldAgentInterrupt(agent = "") {
+ const config = this.getAgentConfig(agent);
+ return this.defaultInterrupt === "ALWAYS" || config.interrupt === "ALWAYS";
+ }
+
+ /**
+ * Select the next node to chat with from a group. The node will be selected based on the history of chats.
+ * It will select the node that has not reached the maximum number of rounds yet and has not chatted with the channel in the last round.
+ * If it could not determine the next node, it will return a random node.
+ *
+ * @param channel The name of the group.
+ * @returns The name of the node to chat with.
+ */
+ async selectNext(channel = "") {
+ // get all members of the group
+ const nodes = this.getGroupMembers(channel);
+ const channelConfig = this.getChannelConfig(channel);
+
+ // TODO: move this to when the group is created
+ // warn if the group is underpopulated
+ if (nodes.length < 3) {
+ console.warn(
+ `- Group (${channel}) is underpopulated with ${nodes.length} agents. Direct communication would be more efficient.`
+ );
+ }
+
+ // get the nodes that have not reached the maximum number of rounds
+ const availableNodes = nodes.filter(
+ (node) => !this.hasReachedMaximumRounds(channel, node)
+ );
+
+ // remove the last node that chatted with the channel, so it doesn't chat again
+ const lastChat = this._chats.filter((c) => c.to === channel).at(-1);
+ if (lastChat) {
+ const index = availableNodes.indexOf(lastChat.from);
+ if (index > -1) {
+ availableNodes.splice(index, 1);
+ }
+ }
+
+ // TODO: what should it do when there is no node to chat with?
+ if (!availableNodes.length) return;
+
+ // get the provider that will be used for the channel
+ // if the channel has a provider, use that otherwise
+ // use the GPT-4 because it has a better reasoning
+ const provider = this.getProviderForConfig({
+ // @ts-expect-error
+ model: "gpt-4",
+ ...this.defaultProvider,
+ ...channelConfig,
+ });
+ const history = this.getHistory({ to: channel });
+
+ // build the messages to send to the provider
+ const messages = [
+ {
+ role: "system",
+ content: channelConfig.role,
+ },
+ {
+ role: "user",
+ content: `You are in a role play game. The following roles are available:
+${availableNodes
+ .map((node) => `@${node}: ${this.getAgentConfig(node).role}`)
+ .join("\n")}.
+
+Read the following conversation.
+
+CHAT HISTORY
+${history.map((c) => `@${c.from}: ${c.content}`).join("\n")}
+
+Then select the next role from that is going to speak next.
+Only return the role.
+`,
+ },
+ ];
+
+ // ask the provider to select the next node to chat with
+ // and remove the @ from the response
+ const { result } = await provider.complete(messages);
+ const name = result?.replace(/^@/g, "");
+ if (this.agents.get(name)) return name;
+
+ // if the name is not in the nodes, return a random node
+ return availableNodes[Math.floor(Math.random() * availableNodes.length)];
+ }
+
+ /**
+ *
+ * @param {string} pluginName this name of the plugin being called
+ * @returns string of the plugin to be called compensating for children denoted by # in the string.
+ * eg: sql-agent:list-database-connections
+ * or is a custom plugin
+ * eg: @@custom-plugin-name
+ */
+ #parseFunctionName(pluginName = "") {
+ if (!pluginName.includes("#") && !pluginName.startsWith("@@"))
+ return pluginName;
+ if (pluginName.startsWith("@@")) return pluginName.replace("@@", "");
+ return pluginName.split("#")[1];
+ }
+
+ /**
+ * Check if the chat has reached the maximum number of rounds.
+ */
+ hasReachedMaximumRounds(from = "", to = "") {
+ return this.getHistory({ from, to }).length >= this.maxRounds;
+ }
+
+ /**
+ * Ask the for the AI provider to generate a reply to the chat.
+ *
+ * @param route.to The node that sent the chat.
+ * @param route.from The node that will reply to the chat.
+ */
+ async reply(route) {
+ // get the provider for the node that will reply
+ const fromConfig = this.getAgentConfig(route.from);
+
+ const chatHistory =
+ // if it is sending message to a group, send the group chat history to the provider
+ // otherwise, send the chat history between the two nodes
+ this.channels.get(route.to)
+ ? [
+ {
+ role: "user",
+ content: `You are in a whatsapp group. Read the following conversation and then reply.
+Do not add introduction or conclusion to your reply because this will be a continuous conversation. Don't introduce yourself.
+
+CHAT HISTORY
+${this.getHistory({ to: route.to })
+ .map((c) => `@${c.from}: ${c.content}`)
+ .join("\n")}
+
+@${route.from}:`,
+ },
+ ]
+ : this.getHistory(route).map((c) => ({
+ content: c.content,
+ role: c.from === route.to ? "user" : "assistant",
+ }));
+
+ // build the messages to send to the provider
+ const messages = [
+ {
+ content: fromConfig.role,
+ role: "system",
+ },
+ // get the history of chats between the two nodes
+ ...chatHistory,
+ ];
+
+ // get the functions that the node can call
+ const functions = fromConfig.functions
+ ?.map((name) => this.functions.get(this.#parseFunctionName(name)))
+ .filter((a) => !!a);
+
+ const provider = this.getProviderForConfig({
+ ...this.defaultProvider,
+ ...fromConfig,
+ });
+
+ // get the chat completion
+ const content = await this.handleExecution(
+ provider,
+ messages,
+ functions,
+ route.from
+ );
+ this.newMessage({ ...route, content });
+
+ return content;
+ }
+
+ async handleExecution(
+ provider,
+ messages = [],
+ functions = [],
+ byAgent = null
+ ) {
+ // get the chat completion
+ const completion = await provider.complete(messages, functions);
+
+ if (completion.functionCall) {
+ const { name, arguments: args } = completion.functionCall;
+ const fn = this.functions.get(name);
+
+ // if provider hallucinated on the function name
+ // ask the provider to complete again
+ if (!fn) {
+ return await this.handleExecution(
+ provider,
+ [
+ ...messages,
+ {
+ name,
+ role: "function",
+ content: `Function "${name}" not found. Try again.`,
+ },
+ ],
+ functions,
+ byAgent
+ );
+ }
+
+ // Execute the function and return the result to the provider
+ fn.caller = byAgent || "agent";
+
+ // If provider is verbose, log the tool call to the frontend
+ if (provider?.verbose) {
+ this?.introspect?.(
+ `[debug]: ${fn.caller} is attempting to call \`${name}\` tool`
+ );
+ }
+
+ // Always log the tool call to the console for debugging purposes
+ this.handlerProps?.log?.(
+ `[debug]: ${fn.caller} is attempting to call \`${name}\` tool`
+ );
+
+ const result = await fn.handler(args);
+ Telemetry.sendTelemetry("agent_tool_call", { tool: name }, null, true);
+
+ // If the tool call has direct output enabled, return the result directly to the chat
+ // without any further processing and no further tool calls will be run.
+ if (this.skipHandleExecution) {
+ this.skipHandleExecution = false; // reset the flag to prevent next tool call from being skipped
+ this?.introspect?.(
+ `The tool call has direct output enabled! The result will be returned directly to the chat without any further processing and no further tool calls will be run.`
+ );
+ this?.introspect?.(`Tool use completed.`);
+ this.handlerProps?.log?.(
+ `${fn.caller} tool call resulted in direct output! Returning raw result as string. NO MORE TOOL CALLS WILL BE EXECUTED.`
+ );
+ return result;
+ }
+
+ return await this.handleExecution(
+ provider,
+ [
+ ...messages,
+ {
+ name,
+ role: "function",
+ content: result,
+ },
+ ],
+ functions,
+ byAgent
+ );
+ }
+
+ return completion?.result;
+ }
+
+ /**
+ * Continue the chat from the last interruption.
+ * If the last chat was not an interruption, it will throw an error.
+ * Provide a feedback where it was interrupted if you want to.
+ *
+ * @param feedback The feedback to the interruption if any.
+ * @returns
+ */
+ async continue(feedback) {
+ const lastChat = this._chats.at(-1);
+ if (!lastChat || lastChat.state !== "interrupt") {
+ throw new Error("No chat to continue");
+ }
+
+ // remove the last chat's that was interrupted
+ this._chats.pop();
+
+ const { from, to } = lastChat;
+
+ if (this.hasReachedMaximumRounds(from, to)) {
+ throw new Error("Maximum rounds reached");
+ }
+
+ if (feedback) {
+ const message = {
+ from,
+ to,
+ content: feedback,
+ };
+
+ // register the message in the chat history
+ this.newMessage(message);
+
+ // ask the node to reply
+ await this.chat({
+ to: message.from,
+ from: message.to,
+ });
+ } else {
+ await this.chat({ from, to });
+ }
+
+ return this;
+ }
+
+ /**
+ * Retry the last chat that threw an error.
+ * If the last chat was not an error, it will throw an error.
+ */
+ async retry() {
+ const lastChat = this._chats.at(-1);
+ if (!lastChat || lastChat.state !== "error") {
+ throw new Error("No chat to retry");
+ }
+
+ // remove the last chat's that threw an error
+ const { from, to } = this?._chats?.pop();
+
+ await this.chat({ from, to });
+ return this;
+ }
+
+ /**
+ * Get the chat history between two nodes or all chats to/from a node.
+ */
+ getHistory({ from, to }) {
+ return this._chats.filter((chat) => {
+ const isSuccess = chat.state === "success";
+
+ // return all chats to the node
+ if (!from) {
+ return isSuccess && chat.to === to;
+ }
+
+ // get all chats from the node
+ if (!to) {
+ return isSuccess && chat.from === from;
+ }
+
+ // check if the chat is between the two nodes
+ const hasSent = chat.from === from && chat.to === to;
+ const hasReceived = chat.from === to && chat.to === from;
+ const mutual = hasSent || hasReceived;
+
+ return isSuccess && mutual;
+ });
+ }
+
+ /**
+ * Get provider based on configurations.
+ * If the provider is a string, it will return the default provider for that string.
+ *
+ * @param config The provider configuration.
+ */
+ getProviderForConfig(config) {
+ if (typeof config.provider === "object") {
+ return config.provider;
+ }
+
+ switch (config.provider) {
+ case "openai":
+ return new Providers.OpenAIProvider({ model: config.model });
+ case "anthropic":
+ return new Providers.AnthropicProvider({ model: config.model });
+ case "lmstudio":
+ return new Providers.LMStudioProvider({ model: config.model });
+ case "ollama":
+ return new Providers.OllamaProvider({ model: config.model });
+ case "groq":
+ return new Providers.GroqProvider({ model: config.model });
+ case "togetherai":
+ return new Providers.TogetherAIProvider({ model: config.model });
+ case "azure":
+ return new Providers.AzureOpenAiProvider({ model: config.model });
+ case "koboldcpp":
+ return new Providers.KoboldCPPProvider({});
+ case "localai":
+ return new Providers.LocalAIProvider({ model: config.model });
+ case "openrouter":
+ return new Providers.OpenRouterProvider({ model: config.model });
+ case "mistral":
+ return new Providers.MistralProvider({ model: config.model });
+ case "generic-openai":
+ return new Providers.GenericOpenAiProvider({ model: config.model });
+ case "perplexity":
+ return new Providers.PerplexityProvider({ model: config.model });
+ case "textgenwebui":
+ return new Providers.TextWebGenUiProvider({});
+ case "bedrock":
+ return new Providers.AWSBedrockProvider({});
+ case "fireworksai":
+ return new Providers.FireworksAIProvider({ model: config.model });
+ case "nvidia-nim":
+ return new Providers.NvidiaNimProvider({ model: config.model });
+ case "moonshotai":
+ return new Providers.MoonshotAiProvider({ model: config.model });
+ case "deepseek":
+ return new Providers.DeepSeekProvider({ model: config.model });
+ case "litellm":
+ return new Providers.LiteLLMProvider({ model: config.model });
+ case "apipie":
+ return new Providers.ApiPieProvider({ model: config.model });
+ case "xai":
+ return new Providers.XAIProvider({ model: config.model });
+ case "novita":
+ return new Providers.NovitaProvider({ model: config.model });
+ case "ppio":
+ return new Providers.PPIOProvider({ model: config.model });
+ case "gemini":
+ return new Providers.GeminiProvider({ model: config.model });
+ case "dpais":
+ return new Providers.DellProAiStudioProvider({ model: config.model });
+ case "cometapi":
+ return new Providers.CometApiProvider({ model: config.model });
+ default:
+ throw new Error(
+ `Unknown provider: ${config.provider}. Please use a valid provider.`
+ );
+ }
+ }
+
+ /**
+ * Register a new function to be called by the AIbitat agents.
+ * You are also required to specify the which node can call the function.
+ * @param functionConfig The function configuration.
+ */
+ function(functionConfig) {
+ this.functions.set(functionConfig.name, functionConfig);
+ return this;
+ }
+}
+
+module.exports = AIbitat;
diff --git a/server/utils/agents/aibitat/plugins/chat-history.js b/server/utils/agents/aibitat/plugins/chat-history.js
new file mode 100644
index 0000000000000000000000000000000000000000..e3123a83bea6a12a5ee888df9311beab4153bd89
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/chat-history.js
@@ -0,0 +1,84 @@
+const { WorkspaceChats } = require("../../../../models/workspaceChats");
+
+/**
+ * Plugin to save chat history to AnythingLLM DB.
+ */
+const chatHistory = {
+ name: "chat-history",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function () {
+ return {
+ name: this.name,
+ setup: function (aibitat) {
+ aibitat.onMessage(async () => {
+ try {
+ const lastResponses = aibitat.chats.slice(-2);
+ if (lastResponses.length !== 2) return;
+ const [prev, last] = lastResponses;
+
+ // We need a full conversation reply with prev being from
+ // the USER and the last being from anyone other than the user.
+ if (prev.from !== "USER" || last.from === "USER") return;
+
+ // If we have a post-reply flow we should save the chat using this special flow
+ // so that post save cleanup and other unique properties can be run as opposed to regular chat.
+ if (aibitat.hasOwnProperty("_replySpecialAttributes")) {
+ await this._storeSpecial(aibitat, {
+ prompt: prev.content,
+ response: last.content,
+ options: aibitat._replySpecialAttributes,
+ });
+ delete aibitat._replySpecialAttributes;
+ return;
+ }
+
+ await this._store(aibitat, {
+ prompt: prev.content,
+ response: last.content,
+ });
+ } catch {}
+ });
+ },
+ _store: async function (aibitat, { prompt, response } = {}) {
+ const invocation = aibitat.handlerProps.invocation;
+ await WorkspaceChats.new({
+ workspaceId: Number(invocation.workspace_id),
+ prompt,
+ response: {
+ text: response,
+ sources: [],
+ type: "chat",
+ },
+ user: { id: invocation?.user_id || null },
+ threadId: invocation?.thread_id || null,
+ });
+ },
+ _storeSpecial: async function (
+ aibitat,
+ { prompt, response, options = {} } = {}
+ ) {
+ const invocation = aibitat.handlerProps.invocation;
+ await WorkspaceChats.new({
+ workspaceId: Number(invocation.workspace_id),
+ prompt,
+ response: {
+ sources: options?.sources ?? [],
+ // when we have a _storeSpecial called the options param can include a storedResponse() function
+ // that will override the text property to store extra information in, depending on the special type of chat.
+ text: options.hasOwnProperty("storedResponse")
+ ? options.storedResponse(response)
+ : response,
+ type: options?.saveAsType ?? "chat",
+ },
+ user: { id: invocation?.user_id || null },
+ threadId: invocation?.thread_id || null,
+ });
+ options?.postSave();
+ },
+ };
+ },
+};
+
+module.exports = { chatHistory };
diff --git a/server/utils/agents/aibitat/plugins/cli.js b/server/utils/agents/aibitat/plugins/cli.js
new file mode 100644
index 0000000000000000000000000000000000000000..fab80c0d480bff4350682ddb3d8df6ad4933947e
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/cli.js
@@ -0,0 +1,135 @@
+// Plugin CAN ONLY BE USE IN DEVELOPMENT.
+const { input } = require("@inquirer/prompts");
+const chalk = require("chalk");
+const { RetryError } = require("../error");
+
+/**
+ * Command-line Interface plugin. It prints the messages on the console and asks for feedback
+ * while the conversation is running in the background.
+ */
+const cli = {
+ name: "cli",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function ({ simulateStream = true } = {}) {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ let printing = [];
+
+ aibitat.onError(async (error) => {
+ let errorMessage =
+ error?.message || "An error occurred while running the agent.";
+ console.error(chalk.red(` error: ${errorMessage}`), error);
+ });
+
+ aibitat.onStart(() => {
+ console.log();
+ console.log("🚀 starting chat ...\n");
+ printing = [Promise.resolve()];
+ });
+
+ aibitat.onMessage(async (message) => {
+ const next = new Promise(async (resolve) => {
+ await Promise.all(printing);
+ await this.print(message, simulateStream);
+ resolve();
+ });
+ printing.push(next);
+ });
+
+ aibitat.onTerminate(async () => {
+ await Promise.all(printing);
+ console.log("🚀 chat finished");
+ });
+
+ aibitat.onInterrupt(async (node) => {
+ await Promise.all(printing);
+ const feedback = await this.askForFeedback(node);
+ // Add an extra line after the message
+ console.log();
+
+ if (feedback === "exit") {
+ console.log("🚀 chat finished");
+ return process.exit(0);
+ }
+
+ await aibitat.continue(feedback);
+ });
+ },
+
+ /**
+ * Print a message on the terminal
+ *
+ * @param message
+ * // message Type { from: string; to: string; content?: string } & {
+ state: 'loading' | 'error' | 'success' | 'interrupt'
+ }
+ * @param simulateStream
+ */
+ print: async function (message = {}, simulateStream = true) {
+ const replying = chalk.dim(`(to ${message.to})`);
+ const reference = `${chalk.magenta("✎")} ${chalk.bold(
+ message.from
+ )} ${replying}:`;
+
+ if (!simulateStream) {
+ console.log(reference);
+ console.log(message.content);
+ // Add an extra line after the message
+ console.log();
+ return;
+ }
+
+ process.stdout.write(`${reference}\n`);
+
+ // Emulate streaming by breaking the cached response into chunks
+ const chunks = message.content?.split(" ") || [];
+ const stream = new ReadableStream({
+ async start(controller) {
+ for (const chunk of chunks) {
+ const bytes = new TextEncoder().encode(chunk + " ");
+ controller.enqueue(bytes);
+ await new Promise((r) =>
+ setTimeout(
+ r,
+ // get a random number between 10ms and 50ms to simulate a random delay
+ Math.floor(Math.random() * 40) + 10
+ )
+ );
+ }
+ controller.close();
+ },
+ });
+
+ // Stream the response to the chat
+ for await (const chunk of stream) {
+ process.stdout.write(new TextDecoder().decode(chunk));
+ }
+
+ // Add an extra line after the message
+ console.log();
+ console.log();
+ },
+
+ /**
+ * Ask for feedback to the user using the terminal
+ *
+ * @param node //{ from: string; to: string }
+ * @returns
+ */
+ askForFeedback: function (node = {}) {
+ return input({
+ message: `Provide feedback to ${chalk.yellow(
+ node.to
+ )} as ${chalk.yellow(
+ node.from
+ )}. Press enter to skip and use auto-reply, or type 'exit' to end the conversation: `,
+ });
+ },
+ };
+ },
+};
+
+module.exports = { cli };
diff --git a/server/utils/agents/aibitat/plugins/file-history.js b/server/utils/agents/aibitat/plugins/file-history.js
new file mode 100644
index 0000000000000000000000000000000000000000..2cab5e1a5ea70100ac48cc47dbc8491e7c70d7f8
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/file-history.js
@@ -0,0 +1,37 @@
+const fs = require("fs");
+const path = require("path");
+
+/**
+ * Plugin to save chat history to a json file
+ */
+const fileHistory = {
+ name: "file-history-plugin",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function ({
+ filename = `history/chat-history-${new Date().toISOString()}.json`,
+ } = {}) {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ const folderPath = path.dirname(filename);
+ // get path from filename
+ if (folderPath) {
+ fs.mkdirSync(folderPath, { recursive: true });
+ }
+
+ aibitat.onMessage(() => {
+ const content = JSON.stringify(aibitat.chats, null, 2);
+ fs.writeFile(filename, content, (err) => {
+ if (err) {
+ console.error(err);
+ }
+ });
+ });
+ },
+ };
+ },
+};
+
+module.exports = { fileHistory };
diff --git a/server/utils/agents/aibitat/plugins/http-socket.js b/server/utils/agents/aibitat/plugins/http-socket.js
new file mode 100644
index 0000000000000000000000000000000000000000..bbfd1cbc2940407ddb545bd99f04507ee369fe44
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/http-socket.js
@@ -0,0 +1,82 @@
+const chalk = require("chalk");
+const { Telemetry } = require("../../../../models/telemetry");
+
+/**
+ * HTTP Interface plugin for Aibitat to emulate a websocket interface in the agent
+ * framework so we dont have to modify the interface for passing messages and responses
+ * in REST or WSS.
+ */
+const httpSocket = {
+ name: "httpSocket",
+ startupConfig: {
+ params: {
+ handler: {
+ required: true,
+ },
+ muteUserReply: {
+ required: false,
+ default: true,
+ },
+ introspection: {
+ required: false,
+ default: true,
+ },
+ },
+ },
+ plugin: function ({
+ handler,
+ muteUserReply = true, // Do not post messages to "USER" back to frontend.
+ introspection = false, // when enabled will attach socket to Aibitat object with .introspect method which reports status updates to frontend.
+ }) {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ aibitat.onError(async (error) => {
+ let errorMessage =
+ error?.message || "An error occurred while running the agent.";
+ console.error(chalk.red(` error: ${errorMessage}`), error);
+ aibitat.introspect(
+ `Error encountered while running: ${errorMessage}`
+ );
+ handler.send(
+ JSON.stringify({ type: "wssFailure", content: errorMessage })
+ );
+ aibitat.terminate();
+ });
+
+ aibitat.introspect = (messageText) => {
+ if (!introspection) return; // Dump thoughts when not wanted.
+ handler.send(
+ JSON.stringify({ type: "statusResponse", content: messageText })
+ );
+ };
+
+ // expose function for sockets across aibitat
+ // type param must be set or else msg will not be shown or handled in UI.
+ aibitat.socket = {
+ send: (type = "__unhandled", content = "") => {
+ handler.send(JSON.stringify({ type, content }));
+ },
+ };
+
+ // We can only receive one message response with HTTP
+ // so we end on first response.
+ aibitat.onMessage((message) => {
+ if (message.from !== "USER")
+ Telemetry.sendTelemetry("agent_chat_sent");
+ if (message.from === "USER" && muteUserReply) return;
+ handler.send(JSON.stringify(message));
+ handler.close();
+ });
+
+ aibitat.onTerminate(() => {
+ handler.close();
+ });
+ },
+ };
+ },
+};
+
+module.exports = {
+ httpSocket,
+};
diff --git a/server/utils/agents/aibitat/plugins/index.js b/server/utils/agents/aibitat/plugins/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..9a7ee7a059f4a281363eebdc8ffad53c8c4c6a93
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/index.js
@@ -0,0 +1,32 @@
+const { webBrowsing } = require("./web-browsing.js");
+const { webScraping } = require("./web-scraping.js");
+const { websocket } = require("./websocket.js");
+const { docSummarizer } = require("./summarize.js");
+const { saveFileInBrowser } = require("./save-file-browser.js");
+const { chatHistory } = require("./chat-history.js");
+const { memory } = require("./memory.js");
+const { rechart } = require("./rechart.js");
+const { sqlAgent } = require("./sql-agent/index.js");
+
+module.exports = {
+ webScraping,
+ webBrowsing,
+ websocket,
+ docSummarizer,
+ saveFileInBrowser,
+ chatHistory,
+ memory,
+ rechart,
+ sqlAgent,
+
+ // Plugin name aliases so they can be pulled by slug as well.
+ [webScraping.name]: webScraping,
+ [webBrowsing.name]: webBrowsing,
+ [websocket.name]: websocket,
+ [docSummarizer.name]: docSummarizer,
+ [saveFileInBrowser.name]: saveFileInBrowser,
+ [chatHistory.name]: chatHistory,
+ [memory.name]: memory,
+ [rechart.name]: rechart,
+ [sqlAgent.name]: sqlAgent,
+};
diff --git a/server/utils/agents/aibitat/plugins/memory.js b/server/utils/agents/aibitat/plugins/memory.js
new file mode 100644
index 0000000000000000000000000000000000000000..b229af3c7e64bd052e90d386f1cdd90d9426e3e0
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/memory.js
@@ -0,0 +1,166 @@
+const { v4 } = require("uuid");
+const { getVectorDbClass, getLLMProvider } = require("../../../helpers");
+const { Deduplicator } = require("../utils/dedupe");
+
+const memory = {
+ name: "rag-memory",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function () {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ tracker: new Deduplicator(),
+ name: this.name,
+ description:
+ "Search against local documents for context that is relevant to the query or store a snippet of text into memory for retrieval later. Storing information should only be done when the user specifically requests for information to be remembered or saved to long-term memory. You should use this tool before search the internet for information. Do not use this tool unless you are explicitly told to 'remember' or 'store' information.",
+ examples: [
+ {
+ prompt: "What is AnythingLLM?",
+ call: JSON.stringify({
+ action: "search",
+ content: "What is AnythingLLM?",
+ }),
+ },
+ {
+ prompt: "What do you know about Plato's motives?",
+ call: JSON.stringify({
+ action: "search",
+ content: "What are the facts about Plato's motives?",
+ }),
+ },
+ {
+ prompt: "Remember that you are a robot",
+ call: JSON.stringify({
+ action: "store",
+ content: "I am a robot, the user told me that i am.",
+ }),
+ },
+ {
+ prompt: "Save that to memory please.",
+ call: JSON.stringify({
+ action: "store",
+ content: "",
+ }),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ action: {
+ type: "string",
+ enum: ["search", "store"],
+ description:
+ "The action we want to take to search for existing similar context or storage of new context.",
+ },
+ content: {
+ type: "string",
+ description:
+ "The plain text to search our local documents with or to store in our vector database.",
+ },
+ },
+ additionalProperties: false,
+ },
+ handler: async function ({ action = "", content = "" }) {
+ try {
+ if (this.tracker.isDuplicate(this.name, { action, content }))
+ return `This was a duplicated call and it's output will be ignored.`;
+
+ let response = "There was nothing to do.";
+ if (action === "search") response = await this.search(content);
+ if (action === "store") response = await this.store(content);
+
+ this.tracker.trackRun(this.name, { action, content });
+ return response;
+ } catch (error) {
+ console.log(error);
+ return `There was an error while calling the function. ${error.message}`;
+ }
+ },
+ search: async function (query = "") {
+ try {
+ const workspace = this.super.handlerProps.invocation.workspace;
+ const LLMConnector = getLLMProvider({
+ provider: workspace?.chatProvider,
+ model: workspace?.chatModel,
+ });
+ const vectorDB = getVectorDbClass();
+ const { contextTexts = [] } =
+ await vectorDB.performSimilaritySearch({
+ namespace: workspace.slug,
+ input: query,
+ LLMConnector,
+ topN: workspace?.topN ?? 4,
+ rerank: workspace?.vectorSearchMode === "rerank",
+ });
+
+ if (contextTexts.length === 0) {
+ this.super.introspect(
+ `${this.caller}: I didn't find anything locally that would help answer this question.`
+ );
+ return "There was no additional context found for that query. We should search the web for this information.";
+ }
+
+ this.super.introspect(
+ `${this.caller}: Found ${contextTexts.length} additional piece of context to help answer this question.`
+ );
+
+ let combinedText = "Additional context for query:\n";
+ for (const text of contextTexts) combinedText += text + "\n\n";
+ return combinedText;
+ } catch (error) {
+ this.super.handlerProps.log(
+ `memory.search raised an error. ${error.message}`
+ );
+ return `An error was raised while searching the vector database. ${error.message}`;
+ }
+ },
+ store: async function (content = "") {
+ try {
+ const workspace = this.super.handlerProps.invocation.workspace;
+ const vectorDB = getVectorDbClass();
+ const { error } = await vectorDB.addDocumentToNamespace(
+ workspace.slug,
+ {
+ docId: v4(),
+ id: v4(),
+ url: "file://embed-via-agent.txt",
+ title: "agent-memory.txt",
+ docAuthor: "@agent",
+ description: "Unknown",
+ docSource: "a text file stored by the workspace agent.",
+ chunkSource: "",
+ published: new Date().toLocaleString(),
+ wordCount: content.split(" ").length,
+ pageContent: content,
+ token_count_estimate: 0,
+ },
+ null
+ );
+
+ if (!!error)
+ return "The content was failed to be embedded properly.";
+ this.super.introspect(
+ `${this.caller}: I saved the content to long-term memory in this workspaces vector database.`
+ );
+ return "The content given was successfully embedded. There is nothing else to do.";
+ } catch (error) {
+ this.super.handlerProps.log(
+ `memory.store raised an error. ${error.message}`
+ );
+ return `Let the user know this action was not successful. An error was raised while storing data in the vector database. ${error.message}`;
+ }
+ },
+ });
+ },
+ };
+ },
+};
+
+module.exports = {
+ memory,
+};
diff --git a/server/utils/agents/aibitat/plugins/rechart.js b/server/utils/agents/aibitat/plugins/rechart.js
new file mode 100644
index 0000000000000000000000000000000000000000..a41ddd6589bb2dd10f4747effca3aa89f22fef8d
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/rechart.js
@@ -0,0 +1,109 @@
+const { safeJsonParse } = require("../../../http");
+const { Deduplicator } = require("../utils/dedupe");
+
+const rechart = {
+ name: "create-chart",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function () {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ // Scrape a website and summarize the content based on objective if the content is too large.',
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ tracker: new Deduplicator(),
+ description:
+ "Generates the JSON data required to generate a RechartJS chart to the user based on their prompt and available data.",
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ type: {
+ type: "string",
+ enum: [
+ "area",
+ "bar",
+ "line",
+ "composed",
+ "scatter",
+ "pie",
+ "radar",
+ "radialBar",
+ "treemap",
+ "funnel",
+ ],
+ description: "The type of chart to be generated.",
+ },
+ title: {
+ type: "string",
+ description:
+ "Title of the chart. There MUST always be a title. Do not leave it blank.",
+ },
+ dataset: {
+ type: "string",
+ description: `Valid JSON in which each element is an object for Recharts API for the 'type' of chart defined WITHOUT new line characters. Strictly using this FORMAT and naming:
+{ "name": "a", "value": 12 }].
+Make sure field "name" always stays named "name". Instead of naming value field value in JSON, name it based on user metric and make it the same across every item.
+Make sure the format use double quotes and property names are string literals. Provide JSON data only.`,
+ },
+ },
+ additionalProperties: false,
+ },
+ required: ["type", "title", "dataset"],
+ handler: async function ({ type, dataset, title }) {
+ try {
+ if (!this.tracker.isUnique(this.name)) {
+ this.super.handlerProps.log(
+ `${this.name} has been run for this chat response already. It can only be called once per chat.`
+ );
+ return "The chart was generated and returned to the user. This function completed successfully. Do not call this function again.";
+ }
+
+ const data = safeJsonParse(dataset, null);
+ if (data === null) {
+ this.super.introspect(
+ `${this.caller}: ${this.name} provided invalid JSON data - so we cant make a ${type} chart.`
+ );
+ return "Invalid JSON provided. Please only provide valid RechartJS JSON to generate a chart.";
+ }
+
+ this.super.introspect(`${this.caller}: Rendering ${type} chart.`);
+ this.super.socket.send("rechartVisualize", {
+ type,
+ dataset,
+ title,
+ });
+
+ this.super._replySpecialAttributes = {
+ saveAsType: "rechartVisualize",
+ storedResponse: (additionalText = "") =>
+ JSON.stringify({
+ type,
+ dataset,
+ title,
+ caption: additionalText,
+ }),
+ postSave: () => this.tracker.removeUniqueConstraint(this.name),
+ };
+
+ this.tracker.markUnique(this.name);
+ return "The chart was generated and returned to the user. This function completed successfully. Do not make another chart.";
+ } catch (error) {
+ this.super.handlerProps.log(
+ `create-chart raised an error. ${error.message}`
+ );
+ return `Let the user know this action was not successful. An error was raised while generating the chart. ${error.message}`;
+ }
+ },
+ });
+ },
+ };
+ },
+};
+
+module.exports = {
+ rechart,
+};
diff --git a/server/utils/agents/aibitat/plugins/save-file-browser.js b/server/utils/agents/aibitat/plugins/save-file-browser.js
new file mode 100644
index 0000000000000000000000000000000000000000..b0ef039f7111c82fcae4f8060deda442ccad0d8d
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/save-file-browser.js
@@ -0,0 +1,96 @@
+const { Deduplicator } = require("../utils/dedupe");
+
+const saveFileInBrowser = {
+ name: "save-file-to-browser",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function () {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ // List and summarize the contents of files that are embedded in the workspace
+ aibitat.function({
+ super: aibitat,
+ tracker: new Deduplicator(),
+ name: this.name,
+ description:
+ "Save content to a file when the user explicitly asks for a download of the file.",
+ examples: [
+ {
+ prompt: "Save me that to a file named 'output'",
+ call: JSON.stringify({
+ file_content:
+ "",
+ filename: "output.txt",
+ }),
+ },
+ {
+ prompt: "Save me that to my desktop",
+ call: JSON.stringify({
+ file_content:
+ "",
+ filename: ".txt",
+ }),
+ },
+ {
+ prompt: "Save me that to a file",
+ call: JSON.stringify({
+ file_content:
+ "",
+ filename: ".txt",
+ }),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ file_content: {
+ type: "string",
+ description: "The content of the file that will be saved.",
+ },
+ filename: {
+ type: "string",
+ description:
+ "filename to save the file as with extension. Extension should be plaintext file extension.",
+ },
+ },
+ additionalProperties: false,
+ },
+ handler: async function ({ file_content = "", filename }) {
+ try {
+ if (
+ this.tracker.isDuplicate(this.name, { file_content, filename })
+ ) {
+ this.super.handlerProps.log(
+ `${this.name} was called, but exited early since it was not a unique call.`
+ );
+ return `${filename} file has been saved successfully!`;
+ }
+
+ this.super.socket.send("fileDownload", {
+ filename,
+ b64Content:
+ "data:text/plain;base64," +
+ Buffer.from(file_content, "utf8").toString("base64"),
+ });
+ this.super.introspect(`${this.caller}: Saving file ${filename}.`);
+ this.tracker.trackRun(this.name, { file_content, filename });
+ return `${filename} file has been saved successfully and will be downloaded automatically to the users browser.`;
+ } catch (error) {
+ this.super.handlerProps.log(
+ `save-file-to-browser raised an error. ${error.message}`
+ );
+ return `Let the user know this action was not successful. An error was raised while saving a file to the browser. ${error.message}`;
+ }
+ },
+ });
+ },
+ };
+ },
+};
+
+module.exports = {
+ saveFileInBrowser,
+};
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js
new file mode 100644
index 0000000000000000000000000000000000000000..d11314f6f26a9ec8c34b6020b0612eee49ec9f05
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js
@@ -0,0 +1,105 @@
+const mssql = require("mssql");
+const { ConnectionStringParser } = require("./utils");
+
+class MSSQLConnector {
+ #connected = false;
+ database_id = "";
+ connectionConfig = {
+ user: null,
+ password: null,
+ database: null,
+ server: null,
+ port: null,
+ pool: {
+ max: 10,
+ min: 0,
+ idleTimeoutMillis: 30000,
+ },
+ options: {
+ encrypt: false,
+ trustServerCertificate: true,
+ },
+ };
+
+ constructor(
+ config = {
+ // we will force into RFC-3986 from DB
+ // eg: mssql://user:password@server:port/database?{...opts}
+ connectionString: null, // we will force into RFC-3986
+ }
+ ) {
+ this.connectionString = config.connectionString;
+ this._client = null;
+ this.#parseDatabase();
+ }
+
+ #parseDatabase() {
+ const connectionParser = new ConnectionStringParser({ scheme: "mssql" });
+ const parsed = connectionParser.parse(this.connectionString);
+
+ this.database_id = parsed?.endpoint;
+ this.connectionConfig = {
+ ...this.connectionConfig,
+ user: parsed?.username,
+ password: parsed?.password,
+ database: parsed?.endpoint,
+ server: parsed?.hosts?.[0]?.host,
+ port: parsed?.hosts?.[0]?.port,
+ options: {
+ ...this.connectionConfig.options,
+ encrypt: parsed?.options?.encrypt === "true",
+ },
+ };
+ }
+
+ async connect() {
+ this._client = await mssql.connect(this.connectionConfig);
+ this.#connected = true;
+ return this._client;
+ }
+
+ /**
+ *
+ * @param {string} queryString the SQL query to be run
+ * @returns {Promise}
+ */
+ async runQuery(queryString = "") {
+ const result = { rows: [], count: 0, error: null };
+ try {
+ if (!this.#connected) await this.connect();
+
+ const query = await this._client.query(queryString);
+ result.rows = query.recordset;
+ result.count = query.rowsAffected.reduce((sum, a) => sum + a, 0);
+ } catch (err) {
+ console.log(this.constructor.name, err);
+ result.error = err.message;
+ } finally {
+ // Check client is connected before closing since we use this for validation
+ if (this._client) {
+ await this._client.close();
+ this.#connected = false;
+ }
+ }
+ return result;
+ }
+
+ async validateConnection() {
+ try {
+ const result = await this.runQuery("SELECT 1");
+ return { success: !result.error, error: result.error };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ }
+
+ getTablesSql() {
+ return `SELECT name FROM sysobjects WHERE xtype='U';`;
+ }
+
+ getTableSchemaSql(table_name) {
+ return `SELECT COLUMN_NAME,COLUMN_DEFAULT,IS_NULLABLE,DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='${table_name}'`;
+ }
+}
+
+module.exports.MSSQLConnector = MSSQLConnector;
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js
new file mode 100644
index 0000000000000000000000000000000000000000..7a566cb725a3ad8a2bec1d336f3a3598d5a55c86
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js
@@ -0,0 +1,71 @@
+const mysql = require("mysql2/promise");
+const { ConnectionStringParser } = require("./utils");
+
+class MySQLConnector {
+ #connected = false;
+ database_id = "";
+ constructor(
+ config = {
+ connectionString: null,
+ }
+ ) {
+ this.connectionString = config.connectionString;
+ this._client = null;
+ this.database_id = this.#parseDatabase();
+ }
+
+ #parseDatabase() {
+ const connectionParser = new ConnectionStringParser({ scheme: "mysql" });
+ const parsed = connectionParser.parse(this.connectionString);
+ return parsed?.endpoint;
+ }
+
+ async connect() {
+ this._client = await mysql.createConnection({ uri: this.connectionString });
+ this.#connected = true;
+ return this._client;
+ }
+
+ /**
+ *
+ * @param {string} queryString the SQL query to be run
+ * @returns {Promise}
+ */
+ async runQuery(queryString = "") {
+ const result = { rows: [], count: 0, error: null };
+ try {
+ if (!this.#connected) await this.connect();
+ const [query] = await this._client.query(queryString);
+ result.rows = query;
+ result.count = query?.length;
+ } catch (err) {
+ console.log(this.constructor.name, err);
+ result.error = err.message;
+ } finally {
+ // Check client is connected before closing since we use this for validation
+ if (this._client) {
+ await this._client.end();
+ this.#connected = false;
+ }
+ }
+ return result;
+ }
+
+ async validateConnection() {
+ try {
+ const result = await this.runQuery("SELECT 1");
+ return { success: !result.error, error: result.error };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ }
+
+ getTablesSql() {
+ return `SELECT table_name FROM information_schema.tables WHERE table_schema = '${this.database_id}'`;
+ }
+ getTableSchemaSql(table_name) {
+ return `SHOW COLUMNS FROM ${this.database_id}.${table_name};`;
+ }
+}
+
+module.exports.MySQLConnector = MySQLConnector;
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js
new file mode 100644
index 0000000000000000000000000000000000000000..15ca6dde428423622e71c2be62c42cb5ddb4af2f
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js
@@ -0,0 +1,66 @@
+const pgSql = require("pg");
+
+class PostgresSQLConnector {
+ #connected = false;
+ constructor(
+ config = {
+ connectionString: null,
+ schema: null,
+ }
+ ) {
+ this.connectionString = config.connectionString;
+ this.schema = config.schema || "public";
+ this._client = new pgSql.Client({
+ connectionString: this.connectionString,
+ });
+ }
+
+ async connect() {
+ await this._client.connect();
+ this.#connected = true;
+ return this._client;
+ }
+
+ /**
+ *
+ * @param {string} queryString the SQL query to be run
+ * @returns {Promise}
+ */
+ async runQuery(queryString = "") {
+ const result = { rows: [], count: 0, error: null };
+ try {
+ if (!this.#connected) await this.connect();
+ const query = await this._client.query(queryString);
+ result.rows = query.rows;
+ result.count = query.rowCount;
+ } catch (err) {
+ console.log(this.constructor.name, err);
+ result.error = err.message;
+ } finally {
+ // Check client is connected before closing since we use this for validation
+ if (this._client) {
+ await this._client.end();
+ this.#connected = false;
+ }
+ }
+ return result;
+ }
+
+ async validateConnection() {
+ try {
+ const result = await this.runQuery("SELECT 1");
+ return { success: !result.error, error: result.error };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ }
+
+ getTablesSql() {
+ return `SELECT * FROM pg_catalog.pg_tables WHERE schemaname = '${this.schema}'`;
+ }
+ getTableSchemaSql(table_name) {
+ return ` select column_name, data_type, character_maximum_length, column_default, is_nullable from INFORMATION_SCHEMA.COLUMNS where table_name = '${table_name}' AND table_schema = '${this.schema}'`;
+ }
+}
+
+module.exports.PostgresSQLConnector = PostgresSQLConnector;
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/index.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..82353683e99322d43a5a8a3e9e1e328a97b4072e
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/index.js
@@ -0,0 +1,80 @@
+const { SystemSettings } = require("../../../../../../models/systemSettings");
+const { safeJsonParse } = require("../../../../../http");
+
+/**
+ * @typedef {('postgresql'|'mysql'|'sql-server')} SQLEngine
+ */
+
+/**
+ * @typedef {Object} QueryResult
+ * @property {[number]} rows - The query result rows
+ * @property {number} count - Number of rows the query returned/changed
+ * @property {string|null} error - Error string if there was an issue
+ */
+
+/**
+ * A valid database SQL connection object
+ * @typedef {Object} SQLConnection
+ * @property {string} database_id - Unique identifier of the database connection
+ * @property {SQLEngine} engine - Engine used by connection
+ * @property {string} connectionString - RFC connection string for db
+ */
+
+/**
+ * @param {SQLEngine} identifier
+ * @param {object} connectionConfig
+ * @returns Database Connection Engine Class for SQLAgent or throws error
+ */
+function getDBClient(identifier = "", connectionConfig = {}) {
+ switch (identifier) {
+ case "mysql":
+ const { MySQLConnector } = require("./MySQL");
+ return new MySQLConnector(connectionConfig);
+ case "postgresql":
+ const { PostgresSQLConnector } = require("./Postgresql");
+ return new PostgresSQLConnector(connectionConfig);
+ case "sql-server":
+ const { MSSQLConnector } = require("./MSSQL");
+ return new MSSQLConnector(connectionConfig);
+ default:
+ throw new Error(
+ `There is no supported database connector for ${identifier}`
+ );
+ }
+}
+
+/**
+ * Lists all of the known database connection that can be used by the agent.
+ * @returns {Promise<[SQLConnection]>}
+ */
+async function listSQLConnections() {
+ return safeJsonParse(
+ (await SystemSettings.get({ label: "agent_sql_connections" }))?.value,
+ []
+ );
+}
+
+/**
+ * Validates a SQL connection by attempting to connect and run a simple query
+ * @param {SQLEngine} identifier - The SQL engine type
+ * @param {object} connectionConfig - The connection configuration
+ * @returns {Promise<{success: boolean, error: string|null}>}
+ */
+async function validateConnection(identifier = "", connectionConfig = {}) {
+ try {
+ const client = getDBClient(identifier, connectionConfig);
+ return await client.validateConnection();
+ } catch (error) {
+ console.log(`Failed to connect to ${identifier} database.`);
+ return {
+ success: false,
+ error: `Unable to connect to ${identifier}. Please verify your connection details.`,
+ };
+ }
+}
+
+module.exports = {
+ getDBClient,
+ listSQLConnections,
+ validateConnection,
+};
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..c93c83a72571978c46788265b3588cb3e4f23da4
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils.js
@@ -0,0 +1,182 @@
+// Credit: https://github.com/sindilevich/connection-string-parser
+
+/**
+ * @typedef {Object} ConnectionStringParserOptions
+ * @property {'mssql' | 'mysql' | 'postgresql' | 'db'} [scheme] - The scheme of the connection string
+ */
+
+/**
+ * @typedef {Object} ConnectionStringObject
+ * @property {string} scheme - The scheme of the connection string eg: mongodb, mssql, mysql, postgresql, etc.
+ * @property {string} username - The username of the connection string
+ * @property {string} password - The password of the connection string
+ * @property {{host: string, port: number}[]} hosts - The hosts of the connection string
+ * @property {string} endpoint - The endpoint (database name) of the connection string
+ * @property {Object} options - The options of the connection string
+ */
+class ConnectionStringParser {
+ static DEFAULT_SCHEME = "db";
+
+ /**
+ * @param {ConnectionStringParserOptions} options
+ */
+ constructor(options = {}) {
+ this.scheme =
+ (options && options.scheme) || ConnectionStringParser.DEFAULT_SCHEME;
+ }
+
+ /**
+ * Takes a connection string object and returns a URI string of the form:
+ *
+ * scheme://[username[:password]@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[endpoint]][?options]
+ * @param {Object} connectionStringObject The object that describes connection string parameters
+ */
+ format(connectionStringObject) {
+ if (!connectionStringObject) {
+ return this.scheme + "://localhost";
+ }
+ if (
+ this.scheme &&
+ connectionStringObject.scheme &&
+ this.scheme !== connectionStringObject.scheme
+ ) {
+ throw new Error(`Scheme not supported: ${connectionStringObject.scheme}`);
+ }
+
+ let uri =
+ (this.scheme ||
+ connectionStringObject.scheme ||
+ ConnectionStringParser.DEFAULT_SCHEME) + "://";
+
+ if (connectionStringObject.username) {
+ uri += encodeURIComponent(connectionStringObject.username);
+ // Allow empty passwords
+ if (connectionStringObject.password) {
+ uri += ":" + encodeURIComponent(connectionStringObject.password);
+ }
+ uri += "@";
+ }
+ uri += this._formatAddress(connectionStringObject);
+ // Only put a slash when there is an endpoint
+ if (connectionStringObject.endpoint) {
+ uri += "/" + encodeURIComponent(connectionStringObject.endpoint);
+ }
+ if (
+ connectionStringObject.options &&
+ Object.keys(connectionStringObject.options).length > 0
+ ) {
+ uri +=
+ "?" +
+ Object.keys(connectionStringObject.options)
+ .map(
+ (option) =>
+ encodeURIComponent(option) +
+ "=" +
+ encodeURIComponent(connectionStringObject.options[option])
+ )
+ .join("&");
+ }
+ return uri;
+ }
+
+ /**
+ * Where scheme and hosts will always be present. Other fields will only be present in the result if they were
+ * present in the input.
+ * @param {string} uri The connection string URI
+ * @returns {ConnectionStringObject} The connection string object
+ */
+ parse(uri) {
+ const connectionStringParser = new RegExp(
+ "^\\s*" + // Optional whitespace padding at the beginning of the line
+ "([^:]+)://" + // Scheme (Group 1)
+ "(?:([^:@,/?=&]+)(?::([^:@,/?=&]+))?@)?" + // User (Group 2) and Password (Group 3)
+ "([^@/?=&]+)" + // Host address(es) (Group 4)
+ "(?:/([^:@,/?=&]+)?)?" + // Endpoint (Group 5)
+ "(?:\\?([^:@,/?]+)?)?" + // Options (Group 6)
+ "\\s*$", // Optional whitespace padding at the end of the line
+ "gi"
+ );
+ const connectionStringObject = {};
+
+ if (!uri.includes("://")) {
+ throw new Error(`No scheme found in URI ${uri}`);
+ }
+
+ const tokens = connectionStringParser.exec(uri);
+
+ if (Array.isArray(tokens)) {
+ connectionStringObject.scheme = tokens[1];
+ if (this.scheme && this.scheme !== connectionStringObject.scheme) {
+ throw new Error(`URI must start with '${this.scheme}://'`);
+ }
+ connectionStringObject.username = tokens[2]
+ ? decodeURIComponent(tokens[2])
+ : tokens[2];
+ connectionStringObject.password = tokens[3]
+ ? decodeURIComponent(tokens[3])
+ : tokens[3];
+ connectionStringObject.hosts = this._parseAddress(tokens[4]);
+ connectionStringObject.endpoint = tokens[5]
+ ? decodeURIComponent(tokens[5])
+ : tokens[5];
+ connectionStringObject.options = tokens[6]
+ ? this._parseOptions(tokens[6])
+ : tokens[6];
+ }
+ return connectionStringObject;
+ }
+
+ /**
+ * Formats the address portion of a connection string
+ * @param {Object} connectionStringObject The object that describes connection string parameters
+ */
+ _formatAddress(connectionStringObject) {
+ return connectionStringObject.hosts
+ .map(
+ (address) =>
+ encodeURIComponent(address.host) +
+ (address.port
+ ? ":" + encodeURIComponent(address.port.toString(10))
+ : "")
+ )
+ .join(",");
+ }
+
+ /**
+ * Parses an address
+ * @param {string} addresses The address(es) to process
+ */
+ _parseAddress(addresses) {
+ return addresses.split(",").map((address) => {
+ const i = address.indexOf(":");
+
+ return i >= 0
+ ? {
+ host: decodeURIComponent(address.substring(0, i)),
+ port: +address.substring(i + 1),
+ }
+ : { host: decodeURIComponent(address) };
+ });
+ }
+
+ /**
+ * Parses options
+ * @param {string} options The options to process
+ */
+ _parseOptions(options) {
+ const result = {};
+
+ options.split("&").forEach((option) => {
+ const i = option.indexOf("=");
+
+ if (i >= 0) {
+ result[decodeURIComponent(option.substring(0, i))] = decodeURIComponent(
+ option.substring(i + 1)
+ );
+ }
+ });
+ return result;
+ }
+}
+
+module.exports = { ConnectionStringParser };
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/get-table-schema.js b/server/utils/agents/aibitat/plugins/sql-agent/get-table-schema.js
new file mode 100644
index 0000000000000000000000000000000000000000..c08a7642561501e68fa5a8f9899031322889e8aa
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/get-table-schema.js
@@ -0,0 +1,98 @@
+module.exports.SqlAgentGetTableSchema = {
+ name: "sql-get-table-schema",
+ plugin: function () {
+ const {
+ listSQLConnections,
+ getDBClient,
+ } = require("./SQLConnectors/index.js");
+
+ return {
+ name: "sql-get-table-schema",
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ description:
+ "Gets the table schema in SQL for a given `table` and `database_id`",
+ examples: [
+ {
+ prompt: "What does the customers table in access-logs look like?",
+ call: JSON.stringify({
+ database_id: "access-logs",
+ table_name: "customers",
+ }),
+ },
+ {
+ prompt:
+ "Get me the full name of a company in records-main, the table should be call comps",
+ call: JSON.stringify({
+ database_id: "records-main",
+ table_name: "comps",
+ }),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ database_id: {
+ type: "string",
+ description:
+ "The database identifier for which we will connect to to query the table schema. This is a required field.",
+ },
+ table_name: {
+ type: "string",
+ description:
+ "The database identifier for the table name we want the schema for. This is a required field.",
+ },
+ },
+ additionalProperties: false,
+ },
+ required: ["database_id", "table_name"],
+ handler: async function ({ database_id = "", table_name = "" }) {
+ this.super.handlerProps.log(`Using the sql-get-table-schema tool.`);
+ try {
+ const databaseConfig = (await listSQLConnections()).find(
+ (db) => db.database_id === database_id
+ );
+ if (!databaseConfig) {
+ this.super.handlerProps.log(
+ `sql-get-table-schema to find config!`,
+ database_id
+ );
+ return `No database connection for ${database_id} was found!`;
+ }
+
+ const db = getDBClient(databaseConfig.engine, databaseConfig);
+ this.super.introspect(
+ `${this.caller}: Querying the table schema for ${table_name} in the ${databaseConfig.database_id} database.`
+ );
+ this.super.introspect(
+ `Running SQL: ${db.getTableSchemaSql(table_name)}`
+ );
+ const result = await db.runQuery(
+ db.getTableSchemaSql(table_name)
+ );
+
+ if (result.error) {
+ this.super.handlerProps.log(
+ `sql-get-table-schema tool reported error`,
+ result.error
+ );
+ this.super.introspect(`Error: ${result.error}`);
+ return `There was an error running the query: ${result.error}`;
+ }
+
+ return JSON.stringify(result);
+ } catch (e) {
+ this.super.handlerProps.log(
+ `sql-get-table-schema raised an error. ${e.message}`
+ );
+ return e.message;
+ }
+ },
+ });
+ },
+ };
+ },
+};
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/index.js b/server/utils/agents/aibitat/plugins/sql-agent/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..b7c1ed7d387507ae65c4df1b8c3fdabd514d210a
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/index.js
@@ -0,0 +1,21 @@
+const { SqlAgentGetTableSchema } = require("./get-table-schema");
+const { SqlAgentListDatabase } = require("./list-database");
+const { SqlAgentListTables } = require("./list-table");
+const { SqlAgentQuery } = require("./query");
+
+const sqlAgent = {
+ name: "sql-agent",
+ startupConfig: {
+ params: {},
+ },
+ plugin: [
+ SqlAgentListDatabase,
+ SqlAgentListTables,
+ SqlAgentGetTableSchema,
+ SqlAgentQuery,
+ ],
+};
+
+module.exports = {
+ sqlAgent,
+};
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/list-database.js b/server/utils/agents/aibitat/plugins/sql-agent/list-database.js
new file mode 100644
index 0000000000000000000000000000000000000000..20e67c281d4a60ee53de76830ec89f57fba789ac
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/list-database.js
@@ -0,0 +1,49 @@
+module.exports.SqlAgentListDatabase = {
+ name: "sql-list-databases",
+ plugin: function () {
+ const { listSQLConnections } = require("./SQLConnectors");
+ return {
+ name: "sql-list-databases",
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ description:
+ "List all available databases via `list_databases` you currently have access to. Returns a unique string identifier `database_id` that can be used for future calls.",
+ examples: [
+ {
+ prompt: "What databases can you access?",
+ call: JSON.stringify({}),
+ },
+ {
+ prompt: "What databases can you tell me about?",
+ call: JSON.stringify({}),
+ },
+ {
+ prompt: "Is there a database named erp-logs you can access?",
+ call: JSON.stringify({}),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {},
+ additionalProperties: false,
+ },
+ handler: async function () {
+ this.super.handlerProps.log(`Using the sql-list-databases tool.`);
+ this.super.introspect(
+ `${this.caller}: Checking what are the available databases.`
+ );
+
+ const connections = (await listSQLConnections()).map((conn) => {
+ const { connectionString, ...rest } = conn;
+ return rest;
+ });
+ return JSON.stringify(connections);
+ },
+ });
+ },
+ };
+ },
+};
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/list-table.js b/server/utils/agents/aibitat/plugins/sql-agent/list-table.js
new file mode 100644
index 0000000000000000000000000000000000000000..1d8e262e9f443cb78a2dc7666847cb0499985aea
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/list-table.js
@@ -0,0 +1,85 @@
+module.exports.SqlAgentListTables = {
+ name: "sql-list-tables",
+ plugin: function () {
+ const {
+ listSQLConnections,
+ getDBClient,
+ } = require("./SQLConnectors/index.js");
+
+ return {
+ name: "sql-list-tables",
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ description:
+ "List all available tables in a database via its `database_id`.",
+ examples: [
+ {
+ prompt: "What tables are there in the `access-logs` database?",
+ call: JSON.stringify({ database_id: "access-logs" }),
+ },
+ {
+ prompt:
+ "What information can you access in the customer_accts postgres db?",
+ call: JSON.stringify({ database_id: "customer_accts" }),
+ },
+ {
+ prompt: "Can you tell me what is in the primary-logs db?",
+ call: JSON.stringify({ database_id: "primary-logs" }),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ database_id: {
+ type: "string",
+ description:
+ "The database identifier for which we will list all tables for. This is a required parameter",
+ },
+ },
+ additionalProperties: false,
+ },
+ required: ["database_id"],
+ handler: async function ({ database_id = "" }) {
+ try {
+ this.super.handlerProps.log(`Using the sql-list-tables tool.`);
+ const databaseConfig = (await listSQLConnections()).find(
+ (db) => db.database_id === database_id
+ );
+ if (!databaseConfig) {
+ this.super.handlerProps.log(
+ `sql-list-tables failed to find config!`,
+ database_id
+ );
+ return `No database connection for ${database_id} was found!`;
+ }
+
+ const db = getDBClient(databaseConfig.engine, databaseConfig);
+ this.super.introspect(
+ `${this.caller}: Checking what are the available tables in the ${databaseConfig.database_id} database.`
+ );
+
+ this.super.introspect(`Running SQL: ${db.getTablesSql()}`);
+ const result = await db.runQuery(db.getTablesSql(database_id));
+ if (result.error) {
+ this.super.handlerProps.log(
+ `sql-list-tables tool reported error`,
+ result.error
+ );
+ this.super.introspect(`Error: ${result.error}`);
+ return `There was an error running the query: ${result.error}`;
+ }
+
+ return JSON.stringify(result);
+ } catch (e) {
+ console.error(e);
+ return e.message;
+ }
+ },
+ });
+ },
+ };
+ },
+};
diff --git a/server/utils/agents/aibitat/plugins/sql-agent/query.js b/server/utils/agents/aibitat/plugins/sql-agent/query.js
new file mode 100644
index 0000000000000000000000000000000000000000..b975b86edf785797d40b16443755f4b7437905cb
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/sql-agent/query.js
@@ -0,0 +1,101 @@
+module.exports.SqlAgentQuery = {
+ name: "sql-query",
+ plugin: function () {
+ const {
+ getDBClient,
+ listSQLConnections,
+ } = require("./SQLConnectors/index.js");
+
+ return {
+ name: "sql-query",
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ description:
+ "Run a read-only SQL query on a `database_id` which will return up rows of data related to the query. The query must only be SELECT statements which do not modify the table data. There should be a reasonable LIMIT on the return quantity to prevent long-running or queries which crash the db.",
+ examples: [
+ {
+ prompt: "How many customers are in dvd-rentals?",
+ call: JSON.stringify({
+ database_id: "dvd-rentals",
+ sql_query: "SELECT * FROM customers",
+ }),
+ },
+ {
+ prompt: "Can you tell me the total volume of sales last month?",
+ call: JSON.stringify({
+ database_id: "sales-db",
+ sql_query:
+ "SELECT SUM(sale_amount) AS total_sales FROM sales WHERE sale_date >= DATEADD(month, -1, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)) AND sale_date < DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)",
+ }),
+ },
+ {
+ prompt:
+ "Do we have anyone in the staff table for our production db named 'sam'? ",
+ call: JSON.stringify({
+ database_id: "production",
+ sql_query:
+ "SElECT * FROM staff WHERE first_name='sam%' OR last_name='sam%'",
+ }),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ database_id: {
+ type: "string",
+ description:
+ "The database identifier for which we will connect to to query the table schema. This is required to run the SQL query.",
+ },
+ sql_query: {
+ type: "string",
+ description:
+ "The raw SQL query to run. Should be a query which does not modify the table and will return results.",
+ },
+ },
+ additionalProperties: false,
+ },
+ required: ["database_id", "table_name"],
+ handler: async function ({ database_id = "", sql_query = "" }) {
+ this.super.handlerProps.log(`Using the sql-query tool.`);
+ try {
+ const databaseConfig = (await listSQLConnections()).find(
+ (db) => db.database_id === database_id
+ );
+ if (!databaseConfig) {
+ this.super.handlerProps.log(
+ `sql-query failed to find config!`,
+ database_id
+ );
+ return `No database connection for ${database_id} was found!`;
+ }
+
+ this.super.introspect(
+ `${this.caller}: Im going to run a query on the ${database_id} to get an answer.`
+ );
+ const db = getDBClient(databaseConfig.engine, databaseConfig);
+
+ this.super.introspect(`Running SQL: ${sql_query}`);
+ const result = await db.runQuery(sql_query);
+ if (result.error) {
+ this.super.handlerProps.log(
+ `sql-query tool reported error`,
+ result.error
+ );
+ this.super.introspect(`Error: ${result.error}`);
+ return `There was an error running the query: ${result.error}`;
+ }
+
+ return JSON.stringify(result);
+ } catch (e) {
+ console.error(e);
+ return e.message;
+ }
+ },
+ });
+ },
+ };
+ },
+};
diff --git a/server/utils/agents/aibitat/plugins/summarize.js b/server/utils/agents/aibitat/plugins/summarize.js
new file mode 100644
index 0000000000000000000000000000000000000000..d532a07159ac2c5052d9c2f28493a357b7448dba
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/summarize.js
@@ -0,0 +1,180 @@
+const { Document } = require("../../../../models/documents");
+const { safeJsonParse } = require("../../../http");
+const { summarizeContent } = require("../utils/summarize");
+const Provider = require("../providers/ai-provider");
+
+const docSummarizer = {
+ name: "document-summarizer",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function () {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ controller: new AbortController(),
+ description:
+ "Can get the list of files available to search with descriptions and can select a single file to open and summarize.",
+ examples: [
+ {
+ prompt: "Summarize example.txt",
+ call: JSON.stringify({
+ action: "summarize",
+ document_filename: "example.txt",
+ }),
+ },
+ {
+ prompt: "What files can you see?",
+ call: JSON.stringify({ action: "list", document_filename: null }),
+ },
+ {
+ prompt: "Tell me about readme.md",
+ call: JSON.stringify({
+ action: "summarize",
+ document_filename: "readme.md",
+ }),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ action: {
+ type: "string",
+ enum: ["list", "summarize"],
+ description:
+ "The action to take. 'list' will return all files available with their filename and descriptions. 'summarize' will open and summarize the file by the a document name.",
+ },
+ document_filename: {
+ type: "string",
+ "x-nullable": true,
+ description:
+ "The file name of the document you want to get the full content of.",
+ },
+ },
+ additionalProperties: false,
+ },
+ handler: async function ({ action, document_filename }) {
+ if (action === "list") return await this.listDocuments();
+ if (action === "summarize")
+ return await this.summarizeDoc(document_filename);
+ return "There is nothing we can do. This function call returns no information.";
+ },
+
+ /**
+ * List all documents available in a workspace
+ * @returns List of files and their descriptions if available.
+ */
+ listDocuments: async function () {
+ try {
+ this.super.introspect(
+ `${this.caller}: Looking at the available documents.`
+ );
+ const documents = await Document.where({
+ workspaceId: this.super.handlerProps.invocation.workspace_id,
+ });
+ if (documents.length === 0)
+ return "No documents found - nothing can be done. Stop.";
+
+ this.super.introspect(
+ `${this.caller}: Found ${documents.length} documents`
+ );
+ const foundDocuments = documents.map((doc) => {
+ const metadata = safeJsonParse(doc.metadata, {});
+ return {
+ document_id: doc.docId,
+ filename: metadata?.title ?? "unknown.txt",
+ description: metadata?.description ?? "no description",
+ };
+ });
+
+ return JSON.stringify(foundDocuments);
+ } catch (error) {
+ this.super.handlerProps.log(
+ `document-summarizer.list raised an error. ${error.message}`
+ );
+ return `Let the user know this action was not successful. An error was raised while listing available files. ${error.message}`;
+ }
+ },
+
+ summarizeDoc: async function (filename) {
+ try {
+ const availableDocs = safeJsonParse(
+ await this.listDocuments(),
+ []
+ );
+ if (!availableDocs.length) {
+ this.super.handlerProps.log(
+ `${this.caller}: No available documents to summarize.`
+ );
+ return "No documents were found.";
+ }
+
+ const docInfo = availableDocs.find(
+ (info) => info.filename === filename
+ );
+ if (!docInfo) {
+ this.super.handlerProps.log(
+ `${this.caller}: No available document by the name "${filename}".`
+ );
+ return `No available document by the name "${filename}".`;
+ }
+
+ const document = await Document.content(docInfo.document_id);
+ this.super.introspect(
+ `${this.caller}: Grabbing all content for ${
+ filename ?? "a discovered file."
+ }`
+ );
+
+ if (!document.content || document.content.length === 0) {
+ throw new Error(
+ "This document has no readable content that could be found."
+ );
+ }
+
+ const { TokenManager } = require("../../../helpers/tiktoken");
+ if (
+ new TokenManager(this.super.model).countFromString(
+ document.content
+ ) < Provider.contextLimit(this.super.provider, this.super.model)
+ ) {
+ return document.content;
+ }
+
+ this.super.introspect(
+ `${this.caller}: Summarizing ${filename ?? ""}...`
+ );
+
+ this.super.onAbort(() => {
+ this.super.handlerProps.log(
+ "Abort was triggered, exiting summarization early."
+ );
+ this.controller.abort();
+ });
+
+ return await summarizeContent({
+ provider: this.super.provider,
+ model: this.super.model,
+ controllerSignal: this.controller.signal,
+ content: document.content,
+ });
+ } catch (error) {
+ this.super.handlerProps.log(
+ `document-summarizer.summarizeDoc raised an error. ${error.message}`
+ );
+ return `Let the user know this action was not successful. An error was raised while summarizing the file. ${error.message}`;
+ }
+ },
+ });
+ },
+ };
+ },
+};
+
+module.exports = {
+ docSummarizer,
+};
diff --git a/server/utils/agents/aibitat/plugins/web-browsing.js b/server/utils/agents/aibitat/plugins/web-browsing.js
new file mode 100644
index 0000000000000000000000000000000000000000..2825b106815896a59a5b54791f45540bfa07a7d2
--- /dev/null
+++ b/server/utils/agents/aibitat/plugins/web-browsing.js
@@ -0,0 +1,730 @@
+const { SystemSettings } = require("../../../../models/systemSettings");
+const { TokenManager } = require("../../../helpers/tiktoken");
+const tiktoken = new TokenManager();
+
+const webBrowsing = {
+ name: "web-browsing",
+ startupConfig: {
+ params: {},
+ },
+ plugin: function () {
+ return {
+ name: this.name,
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ countTokens: (string) =>
+ tiktoken
+ .countFromString(string)
+ .toString()
+ .replace(/\B(?=(\d{3})+(?!\d))/g, ","),
+ description:
+ "Searches for a given query using a search engine to get better results for the user query.",
+ examples: [
+ {
+ prompt: "Who won the world series today?",
+ call: JSON.stringify({ query: "Winner of today's world series" }),
+ },
+ {
+ prompt: "What is AnythingLLM?",
+ call: JSON.stringify({ query: "AnythingLLM" }),
+ },
+ {
+ prompt: "Current AAPL stock price",
+ call: JSON.stringify({ query: "AAPL stock price today" }),
+ },
+ ],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ query: {
+ type: "string",
+ description: "A search query.",
+ },
+ },
+ additionalProperties: false,
+ },
+ handler: async function ({ query }) {
+ try {
+ if (query) return await this.search(query);
+ return "There is nothing we can do. This function call returns no information.";
+ } catch (error) {
+ return `There was an error while calling the function. No data or response was found. Let the user know this was the error: ${error.message}`;
+ }
+ },
+
+ /**
+ * Use Google Custom Search Engines
+ * Free to set up, easy to use, 100 calls/day!
+ * https://programmablesearchengine.google.com/controlpanel/create
+ */
+ search: async function (query) {
+ const provider =
+ (await SystemSettings.get({ label: "agent_search_provider" }))
+ ?.value ?? "unknown";
+ let engine;
+ switch (provider) {
+ case "google-search-engine":
+ engine = "_googleSearchEngine";
+ break;
+ case "searchapi":
+ engine = "_searchApi";
+ break;
+ case "serper-dot-dev":
+ engine = "_serperDotDev";
+ break;
+ case "bing-search":
+ engine = "_bingWebSearch";
+ break;
+ case "serply-engine":
+ engine = "_serplyEngine";
+ break;
+ case "searxng-engine":
+ engine = "_searXNGEngine";
+ break;
+ case "tavily-search":
+ engine = "_tavilySearch";
+ break;
+ case "duckduckgo-engine":
+ engine = "_duckDuckGoEngine";
+ break;
+ case "exa-search":
+ engine = "_exaSearch";
+ break;
+ default:
+ engine = "_googleSearchEngine";
+ }
+ return await this[engine](query);
+ },
+
+ /**
+ * Utility function to truncate a string to a given length for debugging
+ * calls to the API while keeping the actual values mostly intact
+ * @param {string} str - The string to truncate
+ * @param {number} length - The length to truncate the string to
+ * @returns {string} The truncated string
+ */
+ middleTruncate(str, length = 5) {
+ if (str.length <= length) return str;
+ return `${str.slice(0, length)}...${str.slice(-length)}`;
+ },
+
+ /**
+ * Use Google Custom Search Engines
+ * Free to set up, easy to use, 100 calls/day
+ * https://programmablesearchengine.google.com/controlpanel/create
+ */
+ _googleSearchEngine: async function (query) {
+ if (!process.env.AGENT_GSE_CTX || !process.env.AGENT_GSE_KEY) {
+ this.super.introspect(
+ `${this.caller}: I can't use Google searching because the user has not defined the required API keys.\nVisit: https://programmablesearchengine.google.com/controlpanel/create to create the API keys.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ const searchURL = new URL(
+ "https://www.googleapis.com/customsearch/v1"
+ );
+ searchURL.searchParams.append("key", process.env.AGENT_GSE_KEY);
+ searchURL.searchParams.append("cx", process.env.AGENT_GSE_CTX);
+ searchURL.searchParams.append("q", query);
+
+ this.super.introspect(
+ `${this.caller}: Searching on Google for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+ const data = await fetch(searchURL)
+ .then((res) => {
+ if (res.ok) return res.json();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ key: this.middleTruncate(process.env.AGENT_GSE_KEY, 5), cx: this.middleTruncate(process.env.AGENT_GSE_CTX, 5), q: query })}`
+ );
+ })
+ .then((searchResult) => searchResult?.items || [])
+ .then((items) => {
+ return items.map((item) => {
+ return {
+ title: item.title,
+ link: item.link,
+ snippet: item.snippet,
+ };
+ });
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(
+ `${this.name}: Google Search Error: ${e.message}`
+ );
+ return [];
+ });
+
+ if (data.length === 0)
+ return `No information was found online for the search query.`;
+
+ const result = JSON.stringify(data);
+ this.super.introspect(
+ `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
+ );
+ return result;
+ },
+
+ /**
+ * Use SearchApi
+ * SearchApi supports multiple search engines like Google Search, Bing Search, Baidu Search, Google News, YouTube, and many more.
+ * https://www.searchapi.io/
+ */
+ _searchApi: async function (query) {
+ if (!process.env.AGENT_SEARCHAPI_API_KEY) {
+ this.super.introspect(
+ `${this.caller}: I can't use SearchApi searching because the user has not defined the required API key.\nVisit: https://www.searchapi.io/ to create the API key for free.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ this.super.introspect(
+ `${this.caller}: Using SearchApi to search for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+
+ const engine = process.env.AGENT_SEARCHAPI_ENGINE;
+ const params = new URLSearchParams({
+ engine: engine,
+ q: query,
+ });
+
+ const url = `https://www.searchapi.io/api/v1/search?${params.toString()}`;
+ const { response, error } = await fetch(url, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${process.env.AGENT_SEARCHAPI_API_KEY}`,
+ "Content-Type": "application/json",
+ "X-SearchApi-Source": "AnythingLLM",
+ },
+ })
+ .then((res) => {
+ if (res.ok) return res.json();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_SEARCHAPI_API_KEY, 5), q: query })}`
+ );
+ })
+ .then((data) => {
+ return { response: data, error: null };
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(`SearchApi Error: ${e.message}`);
+ return { response: null, error: e.message };
+ });
+ if (error)
+ return `There was an error searching for content. ${error}`;
+
+ const data = [];
+ if (response.hasOwnProperty("knowledge_graph"))
+ data.push(response.knowledge_graph?.description);
+ if (response.hasOwnProperty("answer_box"))
+ data.push(response.answer_box?.answer);
+ response.organic_results?.forEach((searchResult) => {
+ const { title, link, snippet } = searchResult;
+ data.push({
+ title,
+ link,
+ snippet,
+ });
+ });
+
+ if (data.length === 0)
+ return `No information was found online for the search query.`;
+
+ const result = JSON.stringify(data);
+ this.super.introspect(
+ `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
+ );
+ return result;
+ },
+
+ /**
+ * Use Serper.dev
+ * Free to set up, easy to use, 2,500 calls for free one-time
+ * https://serper.dev
+ */
+ _serperDotDev: async function (query) {
+ if (!process.env.AGENT_SERPER_DEV_KEY) {
+ this.super.introspect(
+ `${this.caller}: I can't use Serper.dev searching because the user has not defined the required API key.\nVisit: https://serper.dev to create the API key for free.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ this.super.introspect(
+ `${this.caller}: Using Serper.dev to search for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+ const { response, error } = await fetch(
+ "https://google.serper.dev/search",
+ {
+ method: "POST",
+ headers: {
+ "X-API-KEY": process.env.AGENT_SERPER_DEV_KEY,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ q: query }),
+ redirect: "follow",
+ }
+ )
+ .then((res) => {
+ if (res.ok) return res.json();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_SERPER_DEV_KEY, 5), q: query })}`
+ );
+ })
+ .then((data) => {
+ return { response: data, error: null };
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(`Serper.dev Error: ${e.message}`);
+ return { response: null, error: e.message };
+ });
+ if (error)
+ return `There was an error searching for content. ${error}`;
+
+ const data = [];
+ if (response.hasOwnProperty("knowledgeGraph"))
+ data.push(response.knowledgeGraph);
+ response.organic?.forEach((searchResult) => {
+ const { title, link, snippet } = searchResult;
+ data.push({
+ title,
+ link,
+ snippet,
+ });
+ });
+
+ if (data.length === 0)
+ return `No information was found online for the search query.`;
+
+ const result = JSON.stringify(data);
+ this.super.introspect(
+ `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
+ );
+ return result;
+ },
+ _bingWebSearch: async function (query) {
+ if (!process.env.AGENT_BING_SEARCH_API_KEY) {
+ this.super.introspect(
+ `${this.caller}: I can't use Bing Web Search because the user has not defined the required API key.\nVisit: https://portal.azure.com/ to create the API key.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ const searchURL = new URL(
+ "https://api.bing.microsoft.com/v7.0/search"
+ );
+ searchURL.searchParams.append("q", query);
+
+ this.super.introspect(
+ `${this.caller}: Using Bing Web Search to search for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+
+ const searchResponse = await fetch(searchURL, {
+ headers: {
+ "Ocp-Apim-Subscription-Key":
+ process.env.AGENT_BING_SEARCH_API_KEY,
+ },
+ })
+ .then((res) => {
+ if (res.ok) return res.json();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_BING_SEARCH_API_KEY, 5), q: query })}`
+ );
+ })
+ .then((data) => {
+ const searchResults = data.webPages?.value || [];
+ return searchResults.map((result) => ({
+ title: result.name,
+ link: result.url,
+ snippet: result.snippet,
+ }));
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(
+ `Bing Web Search Error: ${e.message}`
+ );
+ return [];
+ });
+
+ if (searchResponse.length === 0)
+ return `No information was found online for the search query.`;
+
+ const result = JSON.stringify(searchResponse);
+ this.super.introspect(
+ `${this.caller}: I found ${searchResponse.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
+ );
+ return result;
+ },
+ _serplyEngine: async function (
+ query,
+ language = "en",
+ hl = "us",
+ limit = 100,
+ device_type = "desktop",
+ proxy_location = "US"
+ ) {
+ // query (str): The query to search for
+ // hl (str): Host Language code to display results in (reference https://developers.google.com/custom-search/docs/xml_results?hl=en#wsInterfaceLanguages)
+ // limit (int): The maximum number of results to return [10-100, defaults to 100]
+ // device_type: get results based on desktop/mobile (defaults to desktop)
+
+ if (!process.env.AGENT_SERPLY_API_KEY) {
+ this.super.introspect(
+ `${this.caller}: I can't use Serply.io searching because the user has not defined the required API key.\nVisit: https://serply.io to create the API key for free.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ this.super.introspect(
+ `${this.caller}: Using Serply to search for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+
+ const params = new URLSearchParams({
+ q: query,
+ language: language,
+ hl,
+ gl: proxy_location.toUpperCase(),
+ });
+ const url = `https://api.serply.io/v1/search/${params.toString()}`;
+ const { response, error } = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-API-KEY": process.env.AGENT_SERPLY_API_KEY,
+ "Content-Type": "application/json",
+ "User-Agent": "anything-llm",
+ "X-Proxy-Location": proxy_location,
+ "X-User-Agent": device_type,
+ },
+ })
+ .then((res) => {
+ if (res.ok) return res.json();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_SERPLY_API_KEY, 5), q: query })}`
+ );
+ })
+ .then((data) => {
+ if (data?.message === "Unauthorized")
+ throw new Error(
+ "Unauthorized. Please double check your AGENT_SERPLY_API_KEY"
+ );
+ return { response: data, error: null };
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(`Serply Error: ${e.message}`);
+ return { response: null, error: e.message };
+ });
+
+ if (error)
+ return `There was an error searching for content. ${error}`;
+
+ const data = [];
+ response.results?.forEach((searchResult) => {
+ const { title, link, description } = searchResult;
+ data.push({
+ title,
+ link,
+ snippet: description,
+ });
+ });
+
+ if (data.length === 0)
+ return `No information was found online for the search query.`;
+
+ const result = JSON.stringify(data);
+ this.super.introspect(
+ `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
+ );
+ return result;
+ },
+ _searXNGEngine: async function (query) {
+ let searchURL;
+ if (!process.env.AGENT_SEARXNG_API_URL) {
+ this.super.introspect(
+ `${this.caller}: I can't use SearXNG searching because the user has not defined the required base URL.\nPlease set this value in the agent skill settings.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ try {
+ searchURL = new URL(process.env.AGENT_SEARXNG_API_URL);
+ searchURL.searchParams.append("q", encodeURIComponent(query));
+ searchURL.searchParams.append("format", "json");
+ } catch (e) {
+ this.super.handlerProps.log(`SearXNG Search: ${e.message}`);
+ this.super.introspect(
+ `${this.caller}: I can't use SearXNG searching because the url provided is not a valid URL.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ this.super.introspect(
+ `${this.caller}: Using SearXNG to search for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+
+ const { response, error } = await fetch(searchURL.toString(), {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "User-Agent": "anything-llm",
+ },
+ })
+ .then((res) => {
+ if (res.ok) return res.json();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ url: searchURL.toString() })}`
+ );
+ })
+ .then((data) => {
+ return { response: data, error: null };
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(
+ `SearXNG Search Error: ${e.message}`
+ );
+ return { response: null, error: e.message };
+ });
+ if (error)
+ return `There was an error searching for content. ${error}`;
+
+ const data = [];
+ response.results?.forEach((searchResult) => {
+ const { url, title, content, publishedDate } = searchResult;
+ data.push({
+ title,
+ link: url,
+ snippet: content,
+ publishedDate,
+ });
+ });
+
+ if (data.length === 0)
+ return `No information was found online for the search query.`;
+
+ const result = JSON.stringify(data);
+ this.super.introspect(
+ `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
+ );
+ return result;
+ },
+ _tavilySearch: async function (query) {
+ if (!process.env.AGENT_TAVILY_API_KEY) {
+ this.super.introspect(
+ `${this.caller}: I can't use Tavily searching because the user has not defined the required API key.\nVisit: https://tavily.com/ to create the API key.`
+ );
+ return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`;
+ }
+
+ this.super.introspect(
+ `${this.caller}: Using Tavily to search for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+
+ const url = "https://api.tavily.com/search";
+ const { response, error } = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ api_key: process.env.AGENT_TAVILY_API_KEY,
+ query: query,
+ }),
+ })
+ .then((res) => {
+ if (res.ok) return res.json();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_TAVILY_API_KEY, 5), q: query })}`
+ );
+ })
+ .then((data) => {
+ return { response: data, error: null };
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(
+ `Tavily Search Error: ${e.message}`
+ );
+ return { response: null, error: e.message };
+ });
+
+ if (error)
+ return `There was an error searching for content. ${error}`;
+
+ const data = [];
+ response.results?.forEach((searchResult) => {
+ const { title, url, content } = searchResult;
+ data.push({
+ title,
+ link: url,
+ snippet: content,
+ });
+ });
+
+ if (data.length === 0)
+ return `No information was found online for the search query.`;
+
+ const result = JSON.stringify(data);
+ this.super.introspect(
+ `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)`
+ );
+ return result;
+ },
+ _duckDuckGoEngine: async function (query) {
+ this.super.introspect(
+ `${this.caller}: Using DuckDuckGo to search for "${
+ query.length > 100 ? `${query.slice(0, 100)}...` : query
+ }"`
+ );
+
+ const searchURL = new URL("https://html.duckduckgo.com/html");
+ searchURL.searchParams.append("q", query);
+
+ const response = await fetch(searchURL.toString())
+ .then((res) => {
+ if (res.ok) return res.text();
+ throw new Error(
+ `${res.status} - ${res.statusText}. params: ${JSON.stringify({ url: searchURL.toString() })}`
+ );
+ })
+ .catch((e) => {
+ this.super.handlerProps.log(
+ `DuckDuckGo Search Error: ${e.message}`
+ );
+ return null;
+ });
+
+ if (!response) return `There was an error searching DuckDuckGo.`;
+ const html = response;
+ const data = [];
+ const results = html.split('