diff --git a/.vscode/settings.json b/.vscode/settings.json
index d60238c72..14efd3fae 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -5,6 +5,7 @@
"AIbitat",
"allm",
"anythingllm",
+ "Apipie",
"Astra",
"Chartable",
"cleancss",
@@ -18,6 +19,7 @@
"elevenlabs",
"Embeddable",
"epub",
+ "fireworksai",
"GROQ",
"hljs",
"huggingface",
@@ -40,17 +42,18 @@
"pagerender",
"Qdrant",
"royalblue",
- "searxng",
"SearchApi",
+ "searxng",
"Serper",
"Serply",
"streamable",
"textgenwebui",
"togetherai",
- "fireworksai",
"Unembed",
+ "uuidv",
"vectordbs",
"Weaviate",
+ "XAILLM",
"Zilliz"
],
"eslint.experimental.useFlatConfig": true,
diff --git a/README.md b/README.md
index 68c21e4b5..b1f308a14 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,8 @@ AnythingLLM divides your documents into objects called `workspaces`. A Workspace
- [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/)
**Embedder models:**
@@ -116,6 +118,7 @@ AnythingLLM divides your documents into objects called `workspaces`. A Workspace
- [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) support:**
diff --git a/collector/index.js b/collector/index.js
index 2893754af..7c41002da 100644
--- a/collector/index.js
+++ b/collector/index.js
@@ -16,12 +16,14 @@ const extensions = require("./extensions");
const { processRawText } = require("./processRawText");
const { verifyPayloadIntegrity } = require("./middleware/verifyIntegrity");
const app = express();
+const FILE_LIMIT = "3GB";
app.use(cors({ origin: true }));
app.use(
- bodyParser.text(),
- bodyParser.json(),
+ bodyParser.text({ limit: FILE_LIMIT }),
+ bodyParser.json({ limit: FILE_LIMIT }),
bodyParser.urlencoded({
+ limit: FILE_LIMIT,
extended: true,
})
);
diff --git a/collector/package.json b/collector/package.json
index 4ce85e68e..bf6498c06 100644
--- a/collector/package.json
+++ b/collector/package.json
@@ -33,6 +33,7 @@
"mime": "^3.0.0",
"moment": "^2.29.4",
"node-html-parser": "^6.1.13",
+ "node-xlsx": "^0.24.0",
"officeparser": "^4.0.5",
"openai": "4.38.5",
"pdf-parse": "^1.1.1",
@@ -48,4 +49,4 @@
"nodemon": "^2.0.22",
"prettier": "^2.4.1"
}
-}
\ No newline at end of file
+}
diff --git a/collector/processLink/convert/generic.js b/collector/processLink/convert/generic.js
index 2f88a4b32..393f421d5 100644
--- a/collector/processLink/convert/generic.js
+++ b/collector/processLink/convert/generic.js
@@ -27,7 +27,8 @@ async function scrapeGenericUrl(link, textOnly = false) {
}
const url = new URL(link);
- const filename = (url.host + "-" + url.pathname).replace(".", "_");
+ const decodedPathname = decodeURIComponent(url.pathname);
+ const filename = `${url.hostname}${decodedPathname.replace(/\//g, "_")}`;
const data = {
id: v4(),
diff --git a/collector/processSingleFile/convert/asXlsx.js b/collector/processSingleFile/convert/asXlsx.js
new file mode 100644
index 000000000..f21c6f1d9
--- /dev/null
+++ b/collector/processSingleFile/convert/asXlsx.js
@@ -0,0 +1,113 @@
+const { v4 } = require("uuid");
+const xlsx = require("node-xlsx").default;
+const path = require("path");
+const fs = require("fs");
+const {
+ createdDate,
+ trashFile,
+ writeToServerDocuments,
+} = require("../../utils/files");
+const { tokenizeString } = require("../../utils/tokenizer");
+const { default: slugify } = require("slugify");
+
+function convertToCSV(data) {
+ return data
+ .map((row) =>
+ row
+ .map((cell) => {
+ if (cell === null || cell === undefined) return "";
+ if (typeof cell === "string" && cell.includes(","))
+ return `"${cell}"`;
+ return cell;
+ })
+ .join(",")
+ )
+ .join("\n");
+}
+
+async function asXlsx({ fullFilePath = "", filename = "" }) {
+ const documents = [];
+ const folderName = slugify(`${path.basename(filename)}-${v4().slice(0, 4)}`, {
+ lower: true,
+ trim: true,
+ });
+
+ const outFolderPath =
+ process.env.NODE_ENV === "development"
+ ? path.resolve(
+ __dirname,
+ `../../../server/storage/documents/${folderName}`
+ )
+ : path.resolve(process.env.STORAGE_DIR, `documents/${folderName}`);
+
+ try {
+ const workSheetsFromFile = xlsx.parse(fullFilePath);
+ if (!fs.existsSync(outFolderPath))
+ fs.mkdirSync(outFolderPath, { recursive: true });
+
+ for (const sheet of workSheetsFromFile) {
+ try {
+ const { name, data } = sheet;
+ const content = convertToCSV(data);
+
+ if (!content?.length) {
+ console.warn(`Sheet "${name}" is empty. Skipping.`);
+ continue;
+ }
+
+ console.log(`-- Processing sheet: ${name} --`);
+ const sheetData = {
+ id: v4(),
+ url: `file://${path.join(outFolderPath, `${slugify(name)}.csv`)}`,
+ title: `${filename} - Sheet:${name}`,
+ docAuthor: "Unknown",
+ description: `Spreadsheet data from sheet: ${name}`,
+ docSource: "an xlsx file uploaded by the user.",
+ chunkSource: "",
+ published: createdDate(fullFilePath),
+ wordCount: content.split(/\s+/).length,
+ pageContent: content,
+ token_count_estimate: tokenizeString(content).length,
+ };
+
+ const document = writeToServerDocuments(
+ sheetData,
+ `sheet-${slugify(name)}`,
+ outFolderPath
+ );
+ documents.push(document);
+ console.log(
+ `[SUCCESS]: Sheet "${name}" converted & ready for embedding.`
+ );
+ } catch (err) {
+ console.error(`Error processing sheet "${name}":`, err);
+ continue;
+ }
+ }
+ } catch (err) {
+ console.error("Could not process xlsx file!", err);
+ return {
+ success: false,
+ reason: `Error processing ${filename}: ${err.message}`,
+ documents: [],
+ };
+ } finally {
+ trashFile(fullFilePath);
+ }
+
+ if (documents.length === 0) {
+ console.error(`No valid sheets found in ${filename}.`);
+ return {
+ success: false,
+ reason: `No valid sheets found in ${filename}.`,
+ documents: [],
+ };
+ }
+
+ console.log(
+ `[SUCCESS]: ${filename} fully processed. Created ${documents.length} document(s).\n`
+ );
+ return { success: true, reason: null, documents };
+}
+
+module.exports = asXlsx;
diff --git a/collector/processSingleFile/index.js b/collector/processSingleFile/index.js
index bdefb79e0..a00b139ed 100644
--- a/collector/processSingleFile/index.js
+++ b/collector/processSingleFile/index.js
@@ -38,7 +38,7 @@ async function processSingleFile(targetFilename, options = {}) {
};
const fileExtension = path.extname(fullFilePath).toLowerCase();
- if (!fileExtension) {
+ if (fullFilePath.includes(".") && !fileExtension) {
return {
success: false,
reason: `No file extension found. This file cannot be processed.`,
diff --git a/collector/utils/constants.js b/collector/utils/constants.js
index ee9ad22ae..c7beeb4b2 100644
--- a/collector/utils/constants.js
+++ b/collector/utils/constants.js
@@ -11,6 +11,10 @@ const ACCEPTED_MIMES = {
".pptx",
],
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
+ ".xlsx",
+ ],
+
"application/vnd.oasis.opendocument.text": [".odt"],
"application/vnd.oasis.opendocument.presentation": [".odp"],
@@ -41,6 +45,8 @@ const SUPPORTED_FILETYPE_CONVERTERS = {
".odt": "./convert/asOfficeMime.js",
".odp": "./convert/asOfficeMime.js",
+ ".xlsx": "./convert/asXlsx.js",
+
".mbox": "./convert/asMbox.js",
".epub": "./convert/asEPub.js",
diff --git a/collector/utils/extensions/RepoLoader/GithubRepo/RepoLoader/index.js b/collector/utils/extensions/RepoLoader/GithubRepo/RepoLoader/index.js
index 61f208742..61ef2036e 100644
--- a/collector/utils/extensions/RepoLoader/GithubRepo/RepoLoader/index.js
+++ b/collector/utils/extensions/RepoLoader/GithubRepo/RepoLoader/index.js
@@ -29,20 +29,36 @@ class GitHubRepoLoader {
}
#validGithubUrl() {
- const UrlPattern = require("url-pattern");
- const pattern = new UrlPattern(
- "https\\://github.com/(:author)/(:project(*))",
- {
- // fixes project names with special characters (.github)
- segmentValueCharset: "a-zA-Z0-9-._~%/+",
- }
- );
- const match = pattern.match(this.repo);
- if (!match) return false;
+ try {
+ const url = new URL(this.repo);
- this.author = match.author;
- this.project = match.project;
- return true;
+ // Not a github url at all.
+ if (url.hostname !== "github.com") {
+ console.log(
+ `[Github Loader]: Invalid Github URL provided! Hostname must be 'github.com'. Got ${url.hostname}`
+ );
+ return false;
+ }
+
+ // Assume the url is in the format of github.com/{author}/{project}
+ // Remove the first slash from the pathname so we can split it properly.
+ const [author, project, ..._rest] = url.pathname.slice(1).split("/");
+ if (!author || !project) {
+ console.log(
+ `[Github Loader]: Invalid Github URL provided! URL must be in the format of 'github.com/{author}/{project}'. Got ${url.pathname}`
+ );
+ return false;
+ }
+
+ this.author = author;
+ this.project = project;
+ return true;
+ } catch (e) {
+ console.log(
+ `[Github Loader]: Invalid Github URL provided! Error: ${e.message}`
+ );
+ return false;
+ }
}
// Ensure the branch provided actually exists
diff --git a/collector/utils/extensions/WebsiteDepth/index.js b/collector/utils/extensions/WebsiteDepth/index.js
index 2a9994aa5..80be0a1d8 100644
--- a/collector/utils/extensions/WebsiteDepth/index.js
+++ b/collector/utils/extensions/WebsiteDepth/index.js
@@ -108,7 +108,8 @@ async function bulkScrapePages(links, outFolderPath) {
}
const url = new URL(link);
- const filename = (url.host + "-" + url.pathname).replace(".", "_");
+ const decodedPathname = decodeURIComponent(url.pathname);
+ const filename = `${url.hostname}${decodedPathname.replace(/\//g, "_")}`;
const data = {
id: v4(),
diff --git a/collector/utils/files/mime.js b/collector/utils/files/mime.js
index b747d5975..ad3ff5782 100644
--- a/collector/utils/files/mime.js
+++ b/collector/utils/files/mime.js
@@ -1,5 +1,5 @@
const MimeLib = require("mime");
-
+const path = require("path");
class MimeDetector {
nonTextTypes = ["multipart", "image", "model", "audio", "video"];
badMimes = [
@@ -44,8 +44,26 @@ class MimeDetector {
);
}
+ // These are file types that are not detected by the mime library and need to be processed as text files.
+ // You should only add file types that are not detected by the mime library, are parsable as text, and are files
+ // with no extension. Otherwise, their extension should be added to the overrides array.
+ #specialTextFileTypes = ["dockerfile", "jenkinsfile"];
+
+ /**
+ * Returns the MIME type of the file. If the file has no extension found, it will be processed as a text file.
+ * @param {string} filepath
+ * @returns {string}
+ */
getType(filepath) {
- return this.lib.getType(filepath);
+ const parsedMime = this.lib.getType(filepath);
+ if (!!parsedMime) return parsedMime;
+
+ // If the mime could not be parsed, it could be a special file type like Dockerfile or Jenkinsfile
+ // which we can reliably process as text files.
+ const baseName = path.basename(filepath)?.toLowerCase();
+ if (this.#specialTextFileTypes.includes(baseName)) return "text/plain";
+
+ return null;
}
}
diff --git a/collector/yarn.lock b/collector/yarn.lock
index 2786692e0..f991b43fa 100644
--- a/collector/yarn.lock
+++ b/collector/yarn.lock
@@ -2326,6 +2326,13 @@ node-html-parser@^6.1.13:
css-select "^5.1.0"
he "1.2.0"
+node-xlsx@^0.24.0:
+ version "0.24.0"
+ resolved "https://registry.yarnpkg.com/node-xlsx/-/node-xlsx-0.24.0.tgz#a6a365acb18ad37c66c2b254b6ebe0c22dc9dc6f"
+ integrity sha512-1olwK48XK9nXZsyH/FCltvGrQYvXXZuxVitxXXv2GIuRm51aBi1+5KwR4rWM4KeO61sFU+00913WLZTD+AcXEg==
+ dependencies:
+ xlsx "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
+
nodemailer@6.9.13:
version "6.9.13"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.13.tgz#5b292bf1e92645f4852ca872c56a6ba6c4a3d3d6"
@@ -3528,6 +3535,10 @@ ws@8.14.2:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
+"xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz":
+ version "0.20.2"
+ resolved "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz#0f64eeed3f1a46e64724620c3553f2dbd3cd2d7d"
+
xml2js@^0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499"
diff --git a/docker/.env.example b/docker/.env.example
index e67ac5ddd..7bb07ebef 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -105,6 +105,14 @@ GID='1000'
# FIREWORKS_AI_LLM_API_KEY='my-fireworks-ai-key'
# FIREWORKS_AI_LLM_MODEL_PREF='accounts/fireworks/models/llama-v3p1-8b-instruct'
+# 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'
+
###########################################
######## Embedding API SElECTION ##########
###########################################
@@ -215,6 +223,11 @@ GID='1000'
# TTS_OPEN_AI_KEY=sk-example
# TTS_OPEN_AI_VOICE_MODEL=nova
+# TTS_PROVIDER="generic-openai"
+# TTS_OPEN_AI_COMPATIBLE_KEY=sk-example
+# TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL=nova
+# TTS_OPEN_AI_COMPATIBLE_ENDPOINT="https://api.openai.com/v1"
+
# TTS_PROVIDER="elevenlabs"
# TTS_ELEVEN_LABS_KEY=
# TTS_ELEVEN_LABS_VOICE_MODEL=21m00Tcm4TlvDq8ikWAM # Rachel
@@ -270,4 +283,12 @@ GID='1000'
# AGENT_SERPLY_API_KEY=
#------ SearXNG ----------- https://github.com/searxng/searxng
-# AGENT_SEARXNG_API_URL=
\ No newline at end of file
+# AGENT_SEARXNG_API_URL=
+
+###########################################
+######## 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
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c6cac66db..cb3bac7f7 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -22,7 +22,6 @@ const WorkspaceChat = lazy(() => import("@/pages/WorkspaceChat"));
const AdminUsers = lazy(() => import("@/pages/Admin/Users"));
const AdminInvites = lazy(() => import("@/pages/Admin/Invitations"));
const AdminWorkspaces = lazy(() => import("@/pages/Admin/Workspaces"));
-const AdminSystem = lazy(() => import("@/pages/Admin/System"));
const AdminLogs = lazy(() => import("@/pages/Admin/Logging"));
const AdminAgents = lazy(() => import("@/pages/Admin/Agents"));
const GeneralChats = lazy(() => import("@/pages/GeneralSettings/Chats"));
@@ -168,10 +167,6 @@ export default function App() {
path="/settings/workspace-chats"
element={ }
/>
- }
- />
}
diff --git a/frontend/src/components/CanViewChatHistory/index.jsx b/frontend/src/components/CanViewChatHistory/index.jsx
new file mode 100644
index 000000000..44e753531
--- /dev/null
+++ b/frontend/src/components/CanViewChatHistory/index.jsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from "react";
+import { FullScreenLoader } from "@/components/Preloader";
+import System from "@/models/system";
+import paths from "@/utils/paths";
+
+/**
+ * Protects the view from system set ups who cannot view chat history.
+ * If the user cannot view chat history, they are redirected to the home page.
+ * @param {React.ReactNode} children
+ */
+export function CanViewChatHistory({ children }) {
+ const { loading, viewable } = useCanViewChatHistory();
+ if (loading) return ;
+ if (!viewable) {
+ window.location.href = paths.home();
+ return ;
+ }
+
+ return <>{children}>;
+}
+
+/**
+ * Provides the `viewable` state to the children.
+ * @returns {React.ReactNode}
+ */
+export function CanViewChatHistoryProvider({ children }) {
+ const { loading, viewable } = useCanViewChatHistory();
+ if (loading) return null;
+ return <>{children({ viewable })}>;
+}
+
+/**
+ * Hook that fetches the can view chat history state from local storage or the system settings.
+ * @returns {Promise<{viewable: boolean, error: string | null}>}
+ */
+export function useCanViewChatHistory() {
+ const [loading, setLoading] = useState(true);
+ const [viewable, setViewable] = useState(false);
+
+ useEffect(() => {
+ async function fetchViewable() {
+ const { viewable } = await System.fetchCanViewChatHistory();
+ setViewable(viewable);
+ setLoading(false);
+ }
+ fetchViewable();
+ }, []);
+
+ return { loading, viewable };
+}
diff --git a/frontend/src/components/EmbeddingSelection/VoyageAiOptions/index.jsx b/frontend/src/components/EmbeddingSelection/VoyageAiOptions/index.jsx
index 252cb0a7b..b55fc6743 100644
--- a/frontend/src/components/EmbeddingSelection/VoyageAiOptions/index.jsx
+++ b/frontend/src/components/EmbeddingSelection/VoyageAiOptions/index.jsx
@@ -36,6 +36,8 @@ export default function VoyageAiOptions({ settings }) {
"voyage-code-2",
"voyage-large-2",
"voyage-2",
+ "voyage-3",
+ "voyage-3-lite",
].map((model) => {
return (
diff --git a/frontend/src/components/LLMSelection/ApiPieOptions/index.jsx b/frontend/src/components/LLMSelection/ApiPieOptions/index.jsx
new file mode 100644
index 000000000..9bb16ae3d
--- /dev/null
+++ b/frontend/src/components/LLMSelection/ApiPieOptions/index.jsx
@@ -0,0 +1,101 @@
+import System from "@/models/system";
+import { useState, useEffect } from "react";
+
+export default function ApiPieLLMOptions({ settings }) {
+ return (
+
+
+
+
+ APIpie API Key
+
+
+
+ {!settings?.credentialsOnly && (
+
+ )}
+
+
+ );
+}
+
+function APIPieModelSelection({ settings }) {
+ const [groupedModels, setGroupedModels] = useState({});
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function findCustomModels() {
+ setLoading(true);
+ const { models } = await System.customModels("apipie");
+ if (models?.length > 0) {
+ const modelsByOrganization = models.reduce((acc, model) => {
+ acc[model.organization] = acc[model.organization] || [];
+ acc[model.organization].push(model);
+ return acc;
+ }, {});
+
+ setGroupedModels(modelsByOrganization);
+ }
+
+ setLoading(false);
+ }
+ findCustomModels();
+ }, []);
+
+ if (loading || Object.keys(groupedModels).length === 0) {
+ return (
+
+
+ Chat Model Selection
+
+
+
+ -- loading available models --
+
+
+
+ );
+ }
+
+ return (
+
+
+ Chat Model Selection
+
+
+ {Object.keys(groupedModels)
+ .sort()
+ .map((organization) => (
+
+ {groupedModels[organization].map((model) => (
+
+ {model.name}
+
+ ))}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/LLMSelection/AzureAiOptions/index.jsx b/frontend/src/components/LLMSelection/AzureAiOptions/index.jsx
index c8b29e20f..c04f9f3fd 100644
--- a/frontend/src/components/LLMSelection/AzureAiOptions/index.jsx
+++ b/frontend/src/components/LLMSelection/AzureAiOptions/index.jsx
@@ -71,23 +71,6 @@ export default function AzureAiOptions({ settings }) {
-
-
-
- Embedding Deployment Name
-
-
-
-
);
diff --git a/frontend/src/components/LLMSelection/XAiLLMOptions/index.jsx b/frontend/src/components/LLMSelection/XAiLLMOptions/index.jsx
new file mode 100644
index 000000000..d760a8ba4
--- /dev/null
+++ b/frontend/src/components/LLMSelection/XAiLLMOptions/index.jsx
@@ -0,0 +1,114 @@
+import { useState, useEffect } from "react";
+import System from "@/models/system";
+
+export default function XAILLMOptions({ settings }) {
+ const [inputValue, setInputValue] = useState(settings?.XAIApiKey);
+ const [apiKey, setApiKey] = useState(settings?.XAIApiKey);
+
+ return (
+
+
+
+ xAI API Key
+
+ setInputValue(e.target.value)}
+ onBlur={() => setApiKey(inputValue)}
+ />
+
+
+ {!settings?.credentialsOnly && (
+
+ )}
+
+ );
+}
+
+function XAIModelSelection({ apiKey, settings }) {
+ const [customModels, setCustomModels] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function findCustomModels() {
+ if (!apiKey) {
+ setCustomModels([]);
+ setLoading(true);
+ return;
+ }
+
+ try {
+ setLoading(true);
+ const { models } = await System.customModels("xai", apiKey);
+ setCustomModels(models || []);
+ } catch (error) {
+ console.error("Failed to fetch custom models:", error);
+ setCustomModels([]);
+ } finally {
+ setLoading(false);
+ }
+ }
+ findCustomModels();
+ }, [apiKey]);
+
+ if (loading) {
+ return (
+
+
+ Chat Model Selection
+
+
+
+ --loading available models--
+
+
+
+ Enter a valid API key to view all available models for your account.
+
+
+ );
+ }
+
+ return (
+
+
+ Chat Model Selection
+
+
+ {customModels.length > 0 && (
+
+ {customModels.map((model) => {
+ return (
+
+ {model.id}
+
+ );
+ })}
+
+ )}
+
+
+ Select the xAI model you want to use for your conversations.
+
+
+ );
+}
diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FileRow/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FileRow/index.jsx
index ea34b33a4..fc3546c17 100644
--- a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FileRow/index.jsx
+++ b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FileRow/index.jsx
@@ -31,7 +31,7 @@ export default function FileRow({ item, selected, toggleSelection }) {
className="shrink-0 text-base font-bold w-4 h-4 mr-[3px]"
weight="fill"
/>
-
+
{middleTruncate(item.title, 55)}
diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FolderRow/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FolderRow/index.jsx
index bf1581e14..7e2dfffee 100644
--- a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FolderRow/index.jsx
+++ b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/FolderRow/index.jsx
@@ -51,7 +51,7 @@ export default function FolderRow({
className="shrink-0 text-base font-bold w-4 h-4 mr-[3px]"
weight="fill"
/>
-
+
{middleTruncate(item.name, 35)}
diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx
index cc267170b..505c4c22c 100644
--- a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx
+++ b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx
@@ -83,7 +83,7 @@ export default function WorkspaceFileRow({
className="shrink-0 text-base font-bold w-4 h-4 mr-[3px] ml-1"
weight="fill"
/>
-
+
{middleTruncate(item.title, 50)}
diff --git a/frontend/src/components/SettingsButton/index.jsx b/frontend/src/components/SettingsButton/index.jsx
index 19a4a17aa..f53e675f1 100644
--- a/frontend/src/components/SettingsButton/index.jsx
+++ b/frontend/src/components/SettingsButton/index.jsx
@@ -29,9 +29,7 @@ export default function SettingsButton() {
return (
- isVisible({ roles: opt.roles, user, flex: opt.flex })
+ isVisible({ roles: opt.roles, user, flex: opt.flex, hidden: opt.hidden })
);
}
diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx
index 97b088eca..46eba5db9 100644
--- a/frontend/src/components/SettingsSidebar/index.jsx
+++ b/frontend/src/components/SettingsSidebar/index.jsx
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
import showToast from "@/utils/toast";
import System from "@/models/system";
import Option from "./MenuOption";
+import { CanViewChatHistoryProvider } from "../CanViewChatHistory";
export default function SettingsSidebar() {
const { t } = useTranslation();
@@ -208,156 +209,157 @@ function SupportEmail() {
}
const SidebarOptions = ({ user = null, t }) => (
- <>
- }
- user={user}
- childOptions={[
- {
- btnText: t("settings.llm"),
- href: paths.settings.llmPreference(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.vector-database"),
- href: paths.settings.vectorDatabase(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.embedder"),
- href: paths.settings.embedder.modelPreference(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.text-splitting"),
- href: paths.settings.embedder.chunkingPreference(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.voice-speech"),
- href: paths.settings.audioPreference(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.transcription"),
- href: paths.settings.transcriptionPreference(),
- flex: true,
- roles: ["admin"],
- },
- ]}
- />
- }
- user={user}
- childOptions={[
- {
- btnText: t("settings.users"),
- href: paths.settings.users(),
- roles: ["admin", "manager"],
- },
- {
- btnText: t("settings.workspaces"),
- href: paths.settings.workspaces(),
- roles: ["admin", "manager"],
- },
- {
- btnText: t("settings.workspace-chats"),
- href: paths.settings.chats(),
- flex: true,
- roles: ["admin", "manager"],
- },
- {
- btnText: t("settings.invites"),
- href: paths.settings.invites(),
- roles: ["admin", "manager"],
- },
- {
- btnText: t("settings.system"),
- href: paths.settings.system(),
- roles: ["admin", "manager"],
- },
- ]}
- />
- }
- href={paths.settings.agentSkills()}
- user={user}
- flex={true}
- roles={["admin"]}
- />
- }
- href={paths.settings.appearance()}
- user={user}
- flex={true}
- roles={["admin", "manager"]}
- />
- }
- user={user}
- childOptions={[
- {
- btnText: t("settings.embed-chats"),
- href: paths.settings.embedChats(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.embeds"),
- href: paths.settings.embedSetup(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.event-logs"),
- href: paths.settings.logs(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.api-keys"),
- href: paths.settings.apiKeys(),
- flex: true,
- roles: ["admin"],
- },
- {
- btnText: t("settings.browser-extension"),
- href: paths.settings.browserExtension(),
- flex: true,
- roles: ["admin", "manager"],
- },
- ]}
- />
- }
- href={paths.settings.security()}
- user={user}
- flex={true}
- roles={["admin", "manager"]}
- hidden={user?.role}
- />
-
- }
- href={paths.settings.experimental()}
- user={user}
- flex={true}
- roles={["admin"]}
- />
-
- >
+
+ {({ viewable: canViewChatHistory }) => (
+ <>
+ }
+ user={user}
+ childOptions={[
+ {
+ btnText: t("settings.llm"),
+ href: paths.settings.llmPreference(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.vector-database"),
+ href: paths.settings.vectorDatabase(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.embedder"),
+ href: paths.settings.embedder.modelPreference(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.text-splitting"),
+ href: paths.settings.embedder.chunkingPreference(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.voice-speech"),
+ href: paths.settings.audioPreference(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.transcription"),
+ href: paths.settings.transcriptionPreference(),
+ flex: true,
+ roles: ["admin"],
+ },
+ ]}
+ />
+ }
+ user={user}
+ childOptions={[
+ {
+ btnText: t("settings.users"),
+ href: paths.settings.users(),
+ roles: ["admin", "manager"],
+ },
+ {
+ btnText: t("settings.workspaces"),
+ href: paths.settings.workspaces(),
+ roles: ["admin", "manager"],
+ },
+ {
+ hidden: !canViewChatHistory,
+ btnText: t("settings.workspace-chats"),
+ href: paths.settings.chats(),
+ flex: true,
+ roles: ["admin", "manager"],
+ },
+ {
+ btnText: t("settings.invites"),
+ href: paths.settings.invites(),
+ roles: ["admin", "manager"],
+ },
+ ]}
+ />
+ }
+ href={paths.settings.agentSkills()}
+ user={user}
+ flex={true}
+ roles={["admin"]}
+ />
+ }
+ href={paths.settings.appearance()}
+ user={user}
+ flex={true}
+ roles={["admin", "manager"]}
+ />
+ }
+ user={user}
+ childOptions={[
+ {
+ hidden: !canViewChatHistory,
+ btnText: t("settings.embed-chats"),
+ href: paths.settings.embedChats(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.embeds"),
+ href: paths.settings.embedSetup(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.event-logs"),
+ href: paths.settings.logs(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.api-keys"),
+ href: paths.settings.apiKeys(),
+ flex: true,
+ roles: ["admin"],
+ },
+ {
+ btnText: t("settings.browser-extension"),
+ href: paths.settings.browserExtension(),
+ flex: true,
+ roles: ["admin", "manager"],
+ },
+ ]}
+ />
+ }
+ href={paths.settings.security()}
+ user={user}
+ flex={true}
+ roles={["admin", "manager"]}
+ hidden={user?.role}
+ />
+
+ }
+ href={paths.settings.experimental()}
+ user={user}
+ flex={true}
+ roles={["admin"]}
+ />
+
+ >
+ )}
+
);
function HoldToReveal({ children, holdForMs = 3_000 }) {
diff --git a/frontend/src/components/TextToSpeech/OpenAiGenericOptions/index.jsx b/frontend/src/components/TextToSpeech/OpenAiGenericOptions/index.jsx
new file mode 100644
index 000000000..2247544cd
--- /dev/null
+++ b/frontend/src/components/TextToSpeech/OpenAiGenericOptions/index.jsx
@@ -0,0 +1,69 @@
+import React from "react";
+
+export default function OpenAiGenericTextToSpeechOptions({ settings }) {
+ return (
+
+
+
+
+ Base URL
+
+
+
+ This should be the base URL of the OpenAI compatible TTS service you
+ will generate TTS responses from.
+
+
+
+
+
+ API Key
+
+
+
+ Some TTS services require an API key to generate TTS responses -
+ this is optional if your service does not require one.
+
+
+
+
+ Voice Model
+
+
+
+ Most TTS services will have several voice models available, this is
+ the identifier for the voice model you want to use.
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/UserMenu/AccountModal/index.jsx b/frontend/src/components/UserMenu/AccountModal/index.jsx
index 9fac4aaeb..9fe7f60a2 100644
--- a/frontend/src/components/UserMenu/AccountModal/index.jsx
+++ b/frontend/src/components/UserMenu/AccountModal/index.jsx
@@ -135,7 +135,7 @@ export default function AccountModal({ user, hideModal }) {
autoComplete="off"
/>
- Username must be only contain lowercase letters, numbers,
+ Username must only contain lowercase letters, numbers,
underscores, and hyphens with no spaces
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx
index 88d063387..31ac70670 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx
@@ -23,6 +23,7 @@ export default function TTSMessage({ slug, chatId, message }) {
switch (provider) {
case "openai":
+ case "generic-openai":
case "elevenlabs":
return ;
case "piper_local":
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
index f1d7bbe1d..b7da93750 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
@@ -81,11 +81,13 @@ const HistoricalMessage = ({
-
+ {role === "assistant" && (
+
+ )}
{isEditing ? (
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
index b79a4535a..35ec9215d 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
@@ -30,7 +30,7 @@ export function DnDFileUploaderProvider({ workspace, children }) {
const { user } = useUser();
useEffect(() => {
- if (!!user && user.role === "default") return false;
+ if (!!user && user.role === "default") return;
System.checkDocumentProcessorOnline().then((status) => setReady(status));
}, [user]);
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
index cc5ae1491..a14b70ac8 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
@@ -122,9 +122,22 @@ export default function PromptInput({
const pasteText = e.clipboardData.getData("text/plain");
if (pasteText) {
- const newPromptInput = promptInput + pasteText.trim();
+ const textarea = textareaRef.current;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const newPromptInput =
+ promptInput.substring(0, start) +
+ pasteText +
+ promptInput.substring(end);
setPromptInput(newPromptInput);
onChange({ target: { value: newPromptInput } });
+
+ // Set the cursor position after the pasted text
+ // we need to use setTimeout to prevent the cursor from being set to the end of the text
+ setTimeout(() => {
+ textarea.selectionStart = textarea.selectionEnd =
+ start + pasteText.length;
+ }, 0);
}
return;
};
diff --git a/frontend/src/hooks/useGetProvidersModels.js b/frontend/src/hooks/useGetProvidersModels.js
index ece31c2b5..a493438c7 100644
--- a/frontend/src/hooks/useGetProvidersModels.js
+++ b/frontend/src/hooks/useGetProvidersModels.js
@@ -49,6 +49,7 @@ const PROVIDER_DEFAULT_MODELS = {
textgenwebui: [],
"generic-openai": [],
bedrock: [],
+ xai: ["grok-beta"],
};
// For providers with large model lists (e.g. togetherAi) - we subgroup the options
diff --git a/frontend/src/media/llmprovider/apipie.png b/frontend/src/media/llmprovider/apipie.png
new file mode 100644
index 000000000..f7faf5002
Binary files /dev/null and b/frontend/src/media/llmprovider/apipie.png differ
diff --git a/frontend/src/media/llmprovider/xai.png b/frontend/src/media/llmprovider/xai.png
new file mode 100644
index 000000000..93106761e
Binary files /dev/null and b/frontend/src/media/llmprovider/xai.png differ
diff --git a/frontend/src/media/ttsproviders/generic-openai.png b/frontend/src/media/ttsproviders/generic-openai.png
new file mode 100644
index 000000000..302f5dbee
Binary files /dev/null and b/frontend/src/media/ttsproviders/generic-openai.png differ
diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js
index 9c8b1f7df..1039d6de2 100644
--- a/frontend/src/models/system.js
+++ b/frontend/src/models/system.js
@@ -9,6 +9,7 @@ const System = {
footerIcons: "anythingllm_footer_links",
supportEmail: "anythingllm_support_email",
customAppName: "anythingllm_custom_app_name",
+ canViewChatHistory: "anythingllm_can_view_chat_history",
},
ping: async function () {
return await fetch(`${API_BASE}/ping`)
@@ -675,6 +676,36 @@ const System = {
return false;
});
},
+
+ /**
+ * Fetches the can view chat history state from local storage or the system settings.
+ * Notice: This is an instance setting that cannot be changed via the UI and it is cached
+ * in local storage for 24 hours.
+ * @returns {Promise<{viewable: boolean, error: string | null}>}
+ */
+ fetchCanViewChatHistory: async function () {
+ const cache = window.localStorage.getItem(
+ this.cacheKeys.canViewChatHistory
+ );
+ const { viewable, lastFetched } = cache
+ ? safeJsonParse(cache, { viewable: false, lastFetched: 0 })
+ : { viewable: false, lastFetched: 0 };
+
+ // Since this is an instance setting that cannot be changed via the UI,
+ // we can cache it in local storage for a day and if the admin changes it,
+ // they should instruct the users to clear local storage.
+ if (typeof viewable === "boolean" && Date.now() - lastFetched < 8.64e7)
+ return { viewable, error: null };
+
+ const res = await System.keys();
+ const isViewable = res?.DisableViewChatHistory === false;
+
+ window.localStorage.setItem(
+ this.cacheKeys.canViewChatHistory,
+ JSON.stringify({ viewable: isViewable, lastFetched: Date.now() })
+ );
+ return { viewable: isViewable, error: null };
+ },
experimentalFeatures: {
liveSync: LiveDocumentSync,
agentPlugins: AgentPlugins,
diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx b/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx
index 1e5349857..2dae3f7db 100644
--- a/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx
+++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx
@@ -281,3 +281,38 @@ export function SearXNGOptions({ settings }) {
);
}
+
+export function TavilySearchOptions({ settings }) {
+ return (
+ <>
+
+ You can get an API key{" "}
+
+ from Tavily.
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/tavily.svg b/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/tavily.svg
new file mode 100644
index 000000000..ce1551057
--- /dev/null
+++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/tavily.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx b/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx
index 345d3ef05..cba4397c7 100644
--- a/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx
+++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx
@@ -7,6 +7,7 @@ import SerperDotDevIcon from "./icons/serper.png";
import BingSearchIcon from "./icons/bing.png";
import SerplySearchIcon from "./icons/serply.png";
import SearXNGSearchIcon from "./icons/searxng.png";
+import TavilySearchIcon from "./icons/tavily.svg";
import {
CaretUpDown,
MagnifyingGlass,
@@ -22,6 +23,7 @@ import {
BingSearchOptions,
SerplySearchOptions,
SearXNGOptions,
+ TavilySearchOptions,
} from "./SearchProviderOptions";
const SEARCH_PROVIDERS = [
@@ -81,6 +83,14 @@ const SEARCH_PROVIDERS = [
description:
"Free, open-source, internet meta-search engine with no tracking.",
},
+ {
+ name: "Tavily Search",
+ value: "tavily-search",
+ logo: TavilySearchIcon,
+ options: (settings) => ,
+ description:
+ "Tavily Search API. Offers a free tier with 1000 queries per month.",
+ },
];
export default function AgentWebSearchSelection({
diff --git a/frontend/src/pages/Admin/System/index.jsx b/frontend/src/pages/Admin/System/index.jsx
deleted file mode 100644
index dba1872d3..000000000
--- a/frontend/src/pages/Admin/System/index.jsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { useEffect, useState } from "react";
-import Sidebar from "@/components/SettingsSidebar";
-import { isMobile } from "react-device-detect";
-import Admin from "@/models/admin";
-import showToast from "@/utils/toast";
-import CTAButton from "@/components/lib/CTAButton";
-
-export default function AdminSystem() {
- const [saving, setSaving] = useState(false);
- const [hasChanges, setHasChanges] = useState(false);
- const [messageLimit, setMessageLimit] = useState({
- enabled: false,
- limit: 10,
- });
-
- const handleSubmit = async (e) => {
- e.preventDefault();
- setSaving(true);
- await Admin.updateSystemPreferences({
- limit_user_messages: messageLimit.enabled,
- message_limit: messageLimit.limit,
- });
- setSaving(false);
- setHasChanges(false);
- showToast("System preferences updated successfully.", "success");
- };
-
- useEffect(() => {
- async function fetchSettings() {
- const settings = (await Admin.systemPreferences())?.settings;
- if (!settings) return;
- setMessageLimit({
- enabled: settings.limit_user_messages,
- limit: settings.message_limit,
- });
- }
- fetchSettings();
- }, []);
-
- return (
-
- );
-}
diff --git a/frontend/src/pages/Admin/Users/NewUserModal/index.jsx b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
index 3af7ebd4a..ebca2debb 100644
--- a/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
+++ b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx
@@ -2,11 +2,15 @@ import React, { useState } from "react";
import { X } from "@phosphor-icons/react";
import Admin from "@/models/admin";
import { userFromStorage } from "@/utils/request";
-import { RoleHintDisplay } from "..";
+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);
@@ -14,6 +18,8 @@ export default function NewUserModal({ closeModal }) {
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);
@@ -58,13 +64,13 @@ export default function NewUserModal({ closeModal }) {
pattern="^[a-z0-9_-]+$"
onInvalid={(e) =>
e.target.setCustomValidity(
- "Username must be only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
+ "Username must only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
)
}
onChange={(e) => e.target.setCustomValidity("")}
/>
- Username must be only contain lowercase letters, numbers,
+ Username must only contain lowercase letters, numbers,
underscores, and hyphens with no spaces
@@ -110,6 +116,12 @@ export default function NewUserModal({ closeModal }) {
+
{error && Error: {error}
}
After creating a user they will need to login with their initial
diff --git a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
index ec234c2f4..b6c30a852 100644
--- a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
+++ b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
@@ -1,11 +1,15 @@
import React, { useState } from "react";
import { X } from "@phosphor-icons/react";
import Admin from "@/models/admin";
-import { RoleHintDisplay } from "../..";
+import { MessageLimitInput, RoleHintDisplay } from "../..";
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);
@@ -16,6 +20,12 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
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) window.location.reload();
setError(error);
@@ -58,7 +68,7 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
autoComplete="off"
/>
- Username must be only contain lowercase letters, numbers,
+ Username must only contain lowercase letters, numbers,
underscores, and hyphens with no spaces
@@ -103,6 +113,12 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
+
{error && Error: {error}
}
diff --git a/frontend/src/pages/Admin/Users/index.jsx b/frontend/src/pages/Admin/Users/index.jsx
index 408e794aa..ed9f8b3f1 100644
--- a/frontend/src/pages/Admin/Users/index.jsx
+++ b/frontend/src/pages/Admin/Users/index.jsx
@@ -135,3 +135,58 @@ export function RoleHintDisplay({ role }) {
);
}
+
+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="bg-zinc-900 text-white placeholder:text-white/20 text-sm rounded-lg focus:border-white block w-60 p-2.5"
+ />
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx b/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx
index 0ebab72de..f337dc01d 100644
--- a/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx
+++ b/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx
@@ -8,10 +8,13 @@ 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 = [
{
@@ -42,6 +45,14 @@ const PROVIDERS = [
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 }) {
diff --git a/frontend/src/pages/GeneralSettings/Chats/index.jsx b/frontend/src/pages/GeneralSettings/Chats/index.jsx
index a2385aa2d..01dc36122 100644
--- a/frontend/src/pages/GeneralSettings/Chats/index.jsx
+++ b/frontend/src/pages/GeneralSettings/Chats/index.jsx
@@ -11,6 +11,7 @@ import { CaretDown, Download, Sparkle, Trash } from "@phosphor-icons/react";
import { saveAs } from "file-saver";
import { useTranslation } from "react-i18next";
import paths from "@/utils/paths";
+import { CanViewChatHistory } from "@/components/CanViewChatHistory";
const exportOptions = {
csv: {
@@ -106,7 +107,8 @@ export default function WorkspaceChats() {
useEffect(() => {
async function fetchChats() {
- const { chats: _chats, hasPages = false } = await System.chats(offset);
+ const { chats: _chats = [], hasPages = false } =
+ await System.chats(offset);
setChats(_chats);
setCanNext(hasPages);
setLoading(false);
@@ -115,85 +117,87 @@ export default function WorkspaceChats() {
}, [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]"
- >
- {data.name}
-
- ))}
+
+
+
+
+
+
+
+
+ {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]"
+ >
+ {data.name}
+
+ ))}
+
+ {chats.length > 0 && (
+ <>
+
+
+ Clear Chats
+
+
+
+ Order Fine-Tune Model
+
+ >
+ )}
- {chats.length > 0 && (
- <>
-
-
- Clear Chats
-
-
-
- Order Fine-Tune Model
-
- >
- )}
+
+ {t("recorded.description")}
+
-
- {t("recorded.description")}
-
+
-
-
+
);
}
diff --git a/frontend/src/pages/GeneralSettings/EmbedChats/index.jsx b/frontend/src/pages/GeneralSettings/EmbedChats/index.jsx
index 82cb261aa..60e4db174 100644
--- a/frontend/src/pages/GeneralSettings/EmbedChats/index.jsx
+++ b/frontend/src/pages/GeneralSettings/EmbedChats/index.jsx
@@ -11,6 +11,7 @@ import { CaretDown, Download } from "@phosphor-icons/react";
import showToast from "@/utils/toast";
import { saveAs } from "file-saver";
import System from "@/models/system";
+import { CanViewChatHistory } from "@/components/CanViewChatHistory";
const exportOptions = {
csv: {
@@ -88,59 +89,61 @@ export default function EmbedChats() {
}, []);
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]"
- >
- {data.name}
-
- ))}
+
+
+
+
+
+
+
+
+ {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]"
+ >
+ {data.name}
+
+ ))}
+
+
+ {t("embed-chats.description")}
+
-
- {t("embed-chats.description")}
-
+
-
-
+
);
}
diff --git a/frontend/src/pages/GeneralSettings/LLMPreference/index.jsx b/frontend/src/pages/GeneralSettings/LLMPreference/index.jsx
index 9ef667b57..06b39639e 100644
--- a/frontend/src/pages/GeneralSettings/LLMPreference/index.jsx
+++ b/frontend/src/pages/GeneralSettings/LLMPreference/index.jsx
@@ -25,6 +25,8 @@ 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 PreLoader from "@/components/Preloader";
import OpenAiOptions from "@/components/LLMSelection/OpenAiOptions";
@@ -48,6 +50,8 @@ 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 LLMItem from "@/components/LLMSelection/LLMItem";
import { CaretUpDown, MagnifyingGlass, X } from "@phosphor-icons/react";
@@ -219,6 +223,27 @@ export const AVAILABLE_LLM_PROVIDERS = [
description: "Run DeepSeek's powerful LLMs.",
requiredConfig: ["DeepSeekApiKey"],
},
+ {
+ 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: "Generic OpenAI",
value: "generic-openai",
@@ -243,17 +268,12 @@ export const AVAILABLE_LLM_PROVIDERS = [
// requiredConfig: [],
// },
{
- name: "AWS Bedrock",
- value: "bedrock",
- logo: AWSBedrockLogo,
- options: (settings) =>
,
- description: "Run powerful foundation models privately with AWS Bedrock.",
- requiredConfig: [
- "AwsBedrockLLMAccessKeyId",
- "AwsBedrockLLMAccessKey",
- "AwsBedrockLLMRegion",
- "AwsBedrockLLMModel",
- ],
+ name: "xAI",
+ value: "xai",
+ logo: XAILogo,
+ options: (settings) =>
,
+ description: "Run xAI's powerful LLMs like Grok-2 and more.",
+ requiredConfig: ["XAIApiKey", "XAIModelPref"],
},
];
diff --git a/frontend/src/pages/OnboardingFlow/Steps/DataHandling/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/DataHandling/index.jsx
index 39d10e77f..33750cba2 100644
--- a/frontend/src/pages/OnboardingFlow/Steps/DataHandling/index.jsx
+++ b/frontend/src/pages/OnboardingFlow/Steps/DataHandling/index.jsx
@@ -21,6 +21,8 @@ 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";
@@ -202,6 +204,20 @@ export const LLM_SELECTION_PRIVACY = {
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,
+ },
};
export const VECTOR_DB_PRIVACY = {
diff --git a/frontend/src/pages/OnboardingFlow/Steps/LLMPreference/index.jsx b/frontend/src/pages/OnboardingFlow/Steps/LLMPreference/index.jsx
index 01b28136a..c68be6f0b 100644
--- a/frontend/src/pages/OnboardingFlow/Steps/LLMPreference/index.jsx
+++ b/frontend/src/pages/OnboardingFlow/Steps/LLMPreference/index.jsx
@@ -20,6 +20,8 @@ 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 OpenAiOptions from "@/components/LLMSelection/OpenAiOptions";
@@ -43,6 +45,8 @@ 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 LLMItem from "@/components/LLMSelection/LLMItem";
import System from "@/models/system";
@@ -193,6 +197,13 @@ const LLMS = [
options: (settings) =>
,
description: "Run DeepSeek's powerful LLMs.",
},
+ {
+ name: "APIpie",
+ value: "apipie",
+ logo: APIPieLogo,
+ options: (settings) =>
,
+ description: "A unified API of AI services from leading providers",
+ },
{
name: "Generic OpenAI",
value: "generic-openai",
@@ -216,6 +227,13 @@ const LLMS = [
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.",
+ },
];
export default function LLMPreference({
diff --git a/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/index.jsx b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/index.jsx
index 97193d5a0..c59a77e71 100644
--- a/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/index.jsx
+++ b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentLLMSelection/index.jsx
@@ -24,6 +24,9 @@ const ENABLED_PROVIDERS = [
"bedrock",
"fireworksai",
"deepseek",
+ "litellm",
+ "apipie",
+ "xai",
// 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.
diff --git a/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentModelSelection/index.jsx b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentModelSelection/index.jsx
index 4e0a9592c..aeb9db067 100644
--- a/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentModelSelection/index.jsx
+++ b/frontend/src/pages/WorkspaceSettings/AgentConfig/AgentModelSelection/index.jsx
@@ -5,14 +5,30 @@ import paths from "@/utils/paths";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
-// These models do NOT support function calling
+/**
+ * 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 true;
- return (
- ["gpt-3.5-turbo-0301", "gpt-4-turbo-2024-04-09", "gpt-4-turbo"].includes(
- model
- ) === false
- );
+ 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",
+ ].includes(model) === false
+ );
+ }
+
+ return true;
}
export default function AgentModelSelection({
diff --git a/frontend/src/pages/WorkspaceSettings/AgentConfig/index.jsx b/frontend/src/pages/WorkspaceSettings/AgentConfig/index.jsx
index b6994a14d..d0db3a684 100644
--- a/frontend/src/pages/WorkspaceSettings/AgentConfig/index.jsx
+++ b/frontend/src/pages/WorkspaceSettings/AgentConfig/index.jsx
@@ -8,8 +8,10 @@ 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);
@@ -84,21 +86,26 @@ export default function WorkspaceAgentConfiguration({ workspace }) {
workspace={workspace}
setHasChanges={setHasChanges}
/>
- {!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.
-
-
+ {(!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 && (
llm.value === selectedLLM);
+
return (
@@ -155,30 +158,66 @@ export default function WorkspaceLLMSelection({
)}
- {NO_MODEL_SELECTION.includes(selectedLLM) ? (
- <>
- {selectedLLM !== "default" && (
-
-
- Multi-model support is not supported for this provider yet.
-
- This workspace will use{" "}
-
- the model set for the system.
-
-
-
- )}
- >
- ) : (
-
-
-
- )}
+
+
+ );
+}
+
+// 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="bg-zinc-900 text-white placeholder:text-white/20 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/utils/paths.js b/frontend/src/utils/paths.js
index 28d36025c..554cc4599 100644
--- a/frontend/src/utils/paths.js
+++ b/frontend/src/utils/paths.js
@@ -80,9 +80,6 @@ export default {
return `/fine-tuning`;
},
settings: {
- system: () => {
- return `/settings/system-preferences`;
- },
users: () => {
return `/settings/users`;
},
diff --git a/server/.env.example b/server/.env.example
index 80009cfe8..9c513f62f 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -95,6 +95,14 @@ SIG_SALT='salt' # Please generate random string at least 32 chars long.
# COHERE_API_KEY=
# COHERE_MODEL_PREF='command-r'
+# 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'
+
###########################################
######## Embedding API SElECTION ##########
###########################################
@@ -209,6 +217,11 @@ TTS_PROVIDER="native"
# 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_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
@@ -259,4 +272,12 @@ TTS_PROVIDER="native"
# AGENT_SERPLY_API_KEY=
#------ SearXNG ----------- https://github.com/searxng/searxng
-# AGENT_SEARXNG_API_URL=
\ No newline at end of file
+# AGENT_SEARXNG_API_URL=
+
+###########################################
+######## 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
\ No newline at end of file
diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js
index 994c8e416..cf3c310d8 100644
--- a/server/endpoints/admin.js
+++ b/server/endpoints/admin.js
@@ -347,14 +347,6 @@ function adminEndpoints(app) {
: await SystemSettings.get({ label });
switch (label) {
- case "limit_user_messages":
- requestedSettings[label] = setting?.value === "true";
- break;
- case "message_limit":
- requestedSettings[label] = setting?.value
- ? Number(setting.value)
- : 10;
- break;
case "footer_data":
requestedSettings[label] = setting?.value ?? JSON.stringify([]);
break;
@@ -422,13 +414,6 @@ function adminEndpoints(app) {
try {
const embedder = getEmbeddingEngineSelection();
const settings = {
- limit_user_messages:
- (await SystemSettings.get({ label: "limit_user_messages" }))
- ?.value === "true",
- message_limit:
- Number(
- (await SystemSettings.get({ label: "message_limit" }))?.value
- ) || 10,
footer_data:
(await SystemSettings.get({ label: "footer_data" }))?.value ||
JSON.stringify([]),
diff --git a/server/endpoints/api/admin/index.js b/server/endpoints/api/admin/index.js
index 11c9d1c11..c165a9cc0 100644
--- a/server/endpoints/api/admin/index.js
+++ b/server/endpoints/api/admin/index.js
@@ -595,56 +595,6 @@ function apiAdminEndpoints(app) {
}
);
- app.get("/v1/admin/preferences", [validApiKey], async (request, response) => {
- /*
- #swagger.tags = ['Admin']
- #swagger.description = 'Show all multi-user preferences for instance. Methods are disabled until multi user mode is enabled via the UI.'
- #swagger.responses[200] = {
- content: {
- "application/json": {
- schema: {
- type: 'object',
- example: {
- settings: {
- limit_user_messages: false,
- message_limit: 10,
- }
- }
- }
- }
- }
- }
- #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 settings = {
- limit_user_messages:
- (await SystemSettings.get({ label: "limit_user_messages" }))
- ?.value === "true",
- message_limit:
- Number(
- (await SystemSettings.get({ label: "message_limit" }))?.value
- ) || 10,
- };
- response.status(200).json({ settings });
- } catch (e) {
- console.error(e);
- response.sendStatus(500).end();
- }
- });
-
app.post(
"/v1/admin/preferences",
[validApiKey],
@@ -658,8 +608,7 @@ function apiAdminEndpoints(app) {
content: {
"application/json": {
example: {
- limit_user_messages: true,
- message_limit: 5,
+ support_email: "support@example.com",
}
}
}
diff --git a/server/endpoints/api/workspaceThread/index.js b/server/endpoints/api/workspaceThread/index.js
index e7f53698a..0d6eb59c6 100644
--- a/server/endpoints/api/workspaceThread/index.js
+++ b/server/endpoints/api/workspaceThread/index.js
@@ -31,12 +31,14 @@ function apiWorkspaceThreadEndpoints(app) {
type: 'string'
}
#swagger.requestBody = {
- description: 'Optional userId associated with the thread',
+ description: 'Optional userId associated with the thread, thread slug and thread name',
required: false,
content: {
"application/json": {
example: {
- userId: 1
+ userId: 1,
+ name: 'Name',
+ slug: 'thread-slug'
}
}
}
@@ -67,9 +69,9 @@ function apiWorkspaceThreadEndpoints(app) {
}
*/
try {
- const { slug } = request.params;
- let { userId = null } = reqBody(request);
- const workspace = await Workspace.get({ slug });
+ 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();
@@ -83,7 +85,8 @@ function apiWorkspaceThreadEndpoints(app) {
const { thread, message } = await WorkspaceThread.new(
workspace,
- userId ? Number(userId) : null
+ userId ? Number(userId) : null,
+ { name, slug }
);
await Telemetry.sendTelemetry("workspace_thread_created", {
diff --git a/server/endpoints/chat.js b/server/endpoints/chat.js
index 64beefeb6..7e8a72b61 100644
--- a/server/endpoints/chat.js
+++ b/server/endpoints/chat.js
@@ -1,8 +1,6 @@
const { v4: uuidv4 } = require("uuid");
const { reqBody, userFromSession, multiUserMode } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
-const { WorkspaceChats } = require("../models/workspaceChats");
-const { SystemSettings } = require("../models/systemSettings");
const { Telemetry } = require("../models/telemetry");
const { streamChatWithWorkspace } = require("../utils/chats/stream");
const {
@@ -16,6 +14,7 @@ const {
} = 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");
function chatEndpoints(app) {
@@ -48,39 +47,16 @@ function chatEndpoints(app) {
response.setHeader("Connection", "keep-alive");
response.flushHeaders();
- if (multiUserMode(response) && user.role !== ROLES.admin) {
- const limitMessagesSetting = await SystemSettings.get({
- label: "limit_user_messages",
+ 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.`,
});
- const limitMessages = limitMessagesSetting?.value === "true";
-
- if (limitMessages) {
- const messageLimitSetting = await SystemSettings.get({
- label: "message_limit",
- });
- const systemLimit = Number(messageLimitSetting?.value);
-
- if (!!systemLimit) {
- const currentChatCount = await WorkspaceChats.count({
- user_id: user.id,
- createdAt: {
- gte: new Date(new Date() - 24 * 60 * 60 * 1000),
- },
- });
-
- if (currentChatCount >= systemLimit) {
- writeResponseChunk(response, {
- id: uuidv4(),
- type: "abort",
- textResponse: null,
- sources: [],
- close: true,
- error: `You have met your maximum 24 hour chat quota of ${systemLimit} chats set by the instance administrators. Try again later.`,
- });
- return;
- }
- }
- }
+ return;
}
await streamChatWithWorkspace(
@@ -157,41 +133,16 @@ function chatEndpoints(app) {
response.setHeader("Connection", "keep-alive");
response.flushHeaders();
- if (multiUserMode(response) && user.role !== ROLES.admin) {
- const limitMessagesSetting = await SystemSettings.get({
- label: "limit_user_messages",
+ 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.`,
});
- const limitMessages = limitMessagesSetting?.value === "true";
-
- if (limitMessages) {
- const messageLimitSetting = await SystemSettings.get({
- label: "message_limit",
- });
- const systemLimit = Number(messageLimitSetting?.value);
-
- if (!!systemLimit) {
- // Chat qty includes all threads because any user can freely
- // create threads and would bypass this rule.
- const currentChatCount = await WorkspaceChats.count({
- user_id: user.id,
- createdAt: {
- gte: new Date(new Date() - 24 * 60 * 60 * 1000),
- },
- });
-
- if (currentChatCount >= systemLimit) {
- writeResponseChunk(response, {
- id: uuidv4(),
- type: "abort",
- textResponse: null,
- sources: [],
- close: true,
- error: `You have met your maximum 24 hour chat quota of ${systemLimit} chats set by the instance administrators. Try again later.`,
- });
- return;
- }
- }
- }
+ return;
}
await streamChatWithWorkspace(
diff --git a/server/endpoints/embed/index.js b/server/endpoints/embed/index.js
index 25e7cb48e..7db2539f8 100644
--- a/server/endpoints/embed/index.js
+++ b/server/endpoints/embed/index.js
@@ -56,6 +56,7 @@ function embeddedEndpoints(app) {
writeResponseChunk(response, {
id: uuidv4(),
type: "abort",
+ sources: [],
textResponse: null,
close: true,
error: e.message,
@@ -72,11 +73,15 @@ function embeddedEndpoints(app) {
try {
const { sessionId } = request.params;
const embed = response.locals.embedConfig;
+ const history = await EmbedChats.forEmbedByUser(
+ embed.id,
+ sessionId,
+ null,
+ null,
+ true
+ );
- const history = await EmbedChats.forEmbedByUser(embed.id, sessionId);
- response.status(200).json({
- history: convertToChatHistory(history),
- });
+ response.status(200).json({ history: convertToChatHistory(history) });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500).end();
diff --git a/server/endpoints/embedManagement.js b/server/endpoints/embedManagement.js
index 7ebab23e7..8bee4dd75 100644
--- a/server/endpoints/embedManagement.js
+++ b/server/endpoints/embedManagement.js
@@ -1,7 +1,6 @@
const { EmbedChats } = require("../models/embedChats");
const { EmbedConfig } = require("../models/embedConfig");
const { EventLogs } = require("../models/eventLogs");
-const { Workspace } = require("../models/workspace");
const { reqBody, userFromSession } = require("../utils/http");
const { validEmbedConfigId } = require("../utils/middleware/embedMiddleware");
const {
@@ -9,6 +8,9 @@ const {
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const {
+ chatHistoryViewable,
+} = require("../utils/middleware/chatHistoryViewable");
function embedManagementEndpoints(app) {
if (!app) return;
@@ -90,7 +92,7 @@ function embedManagementEndpoints(app) {
app.post(
"/embed/chats",
- [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ [chatHistoryViewable, validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const { offset = 0, limit = 20 } = reqBody(request);
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index e4f38a686..be7cf3478 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -55,6 +55,9 @@ const {
const { SlashCommandPresets } = require("../models/slashCommandsPresets");
const { EncryptionManager } = require("../utils/EncryptionManager");
const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
+const {
+ chatHistoryViewable,
+} = require("../utils/middleware/chatHistoryViewable");
function systemEndpoints(app) {
if (!app) return;
@@ -495,8 +498,6 @@ function systemEndpoints(app) {
await SystemSettings._updateSettings({
multi_user_mode: true,
- limit_user_messages: false,
- message_limit: 25,
});
await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);
@@ -968,7 +969,11 @@ function systemEndpoints(app) {
app.post(
"/system/workspace-chats",
- [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ [
+ chatHistoryViewable,
+ validatedRequest,
+ flexUserRoleValid([ROLES.admin, ROLES.manager]),
+ ],
async (request, response) => {
try {
const { offset = 0, limit = 20 } = reqBody(request);
@@ -1008,7 +1013,11 @@ function systemEndpoints(app) {
app.get(
"/system/export-chats",
- [validatedRequest, flexUserRoleValid([ROLES.manager, ROLES.admin])],
+ [
+ chatHistoryViewable,
+ validatedRequest,
+ flexUserRoleValid([ROLES.manager, ROLES.admin]),
+ ],
async (request, response) => {
try {
const { type = "jsonl", chatType = "workspace" } = request.query;
diff --git a/server/models/embedChats.js b/server/models/embedChats.js
index 1c46f6d4a..9f11b1c6e 100644
--- a/server/models/embedChats.js
+++ b/server/models/embedChats.js
@@ -1,5 +1,17 @@
+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,
@@ -25,11 +37,36 @@ const EmbedChats = {
}
},
+ /**
+ * 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
+ orderBy = null,
+ filterSources = false
) {
if (!embedId || !sessionId) return [];
@@ -43,7 +80,7 @@ const EmbedChats = {
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }),
});
- return chats;
+ return filterSources ? this.filterSources(chats) : chats;
} catch (error) {
console.error(error.message);
return [];
diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js
index 97d3b1993..46b3539bb 100644
--- a/server/models/systemSettings.js
+++ b/server/models/systemSettings.js
@@ -21,8 +21,6 @@ function isNullOrNaN(value) {
const SystemSettings = {
protectedFields: ["multi_user_mode"],
publicFields: [
- "limit_user_messages",
- "message_limit",
"footer_data",
"support_email",
"text_splitter_chunk_size",
@@ -38,8 +36,6 @@ const SystemSettings = {
"meta_page_favicon",
],
supportedFields: [
- "limit_user_messages",
- "message_limit",
"logo_filename",
"telemetry_id",
"footer_data",
@@ -108,6 +104,7 @@ const SystemSettings = {
"bing-search",
"serply-engine",
"searxng-engine",
+ "tavily-search",
].includes(update)
)
throw new Error("Invalid SERP provider.");
@@ -229,12 +226,18 @@ const SystemSettings = {
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,
+ TTSOpenAICompatibleVoiceModel:
+ process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL,
+ TTSOpenAICompatibleEndpoint: process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT,
// --------------------------------------------------------
// Agent Settings & Configs
@@ -247,6 +250,14 @@ const SystemSettings = {
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,
+
+ // --------------------------------------------------------
+ // Compliance Settings
+ // --------------------------------------------------------
+ // Disable View Chat History for the whole instance.
+ DisableViewChatHistory:
+ "DISABLE_VIEW_CHAT_HISTORY" in process.env || false,
};
},
@@ -515,6 +526,14 @@ const SystemSettings = {
// 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,
};
},
diff --git a/server/models/user.js b/server/models/user.js
index a149a45ea..e6915d9dc 100644
--- a/server/models/user.js
+++ b/server/models/user.js
@@ -1,6 +1,17 @@
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: [
@@ -10,6 +21,7 @@ const User = {
"pfpFilename",
"role",
"suspended",
+ "dailyMessageLimit",
],
validations: {
username: (newValue = "") => {
@@ -32,12 +44,24 @@ const User = {
}
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;
+ },
},
// 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);
}
@@ -48,7 +72,12 @@ const User = {
return { ...rest };
},
- create: async function ({ username, password, role = "default" }) {
+ create: async function ({
+ username,
+ password,
+ role = "default",
+ dailyMessageLimit = null,
+ }) {
const passwordCheck = this.checkPasswordComplexity(password);
if (!passwordCheck.checkedOK) {
return { user: null, error: passwordCheck.error };
@@ -58,7 +87,7 @@ const User = {
// Do not allow new users to bypass validation
if (!this.usernameRegex.test(username))
throw new Error(
- "Username must be only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
+ "Username must only contain lowercase letters, numbers, underscores, and hyphens with no spaces"
);
const bcrypt = require("bcrypt");
@@ -68,6 +97,8 @@ const User = {
username: this.validations.username(username),
password: hashedPassword,
role: this.validations.role(role),
+ dailyMessageLimit:
+ this.validations.dailyMessageLimit(dailyMessageLimit),
},
});
return { user: this.filterFields(user), error: null };
@@ -135,7 +166,7 @@ const User = {
return {
success: false,
error:
- "Username must be only contain lowercase letters, numbers, underscores, and hyphens with no spaces",
+ "Username must only contain lowercase letters, numbers, underscores, and hyphens with no spaces",
};
const user = await prisma.users.update({
@@ -260,6 +291,29 @@ const User = {
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/workspaceThread.js b/server/models/workspaceThread.js
index 32e9f89b6..1ac6040cd 100644
--- a/server/models/workspaceThread.js
+++ b/server/models/workspaceThread.js
@@ -1,16 +1,44 @@
const prisma = require("../utils/prisma");
+const slugifyModule = require("slugify");
const { v4: uuidv4 } = require("uuid");
const WorkspaceThread = {
defaultName: "Thread",
writable: ["name"],
- new: async function (workspace, userId = null) {
+ /**
+ * 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: this.defaultName,
- slug: uuidv4(),
+ 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,
},
diff --git a/server/prisma/migrations/20241003192954_init/migration.sql b/server/prisma/migrations/20241003192954_init/migration.sql
new file mode 100644
index 000000000..e3d26d35c
--- /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/schema.prisma b/server/prisma/schema.prisma
index 8e1ffd616..25df9c1d9 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -67,6 +67,7 @@ model users {
seen_recovery_codes Boolean? @default(false)
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
+ dailyMessageLimit Int?
workspace_chats workspace_chats[]
workspace_users workspace_users[]
embed_configs embed_configs[]
@@ -309,4 +310,4 @@ model browser_extension_api_keys {
user users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
-}
\ No newline at end of file
+}
diff --git a/server/prisma/seed.js b/server/prisma/seed.js
index c58e45569..202ac04b3 100644
--- a/server/prisma/seed.js
+++ b/server/prisma/seed.js
@@ -4,8 +4,6 @@ const prisma = new PrismaClient();
async function main() {
const settings = [
{ label: "multi_user_mode", value: "false" },
- { label: "limit_user_messages", value: "false" },
- { label: "message_limit", value: "25" },
{ label: "logo_filename", value: "anything-llm.png" },
];
diff --git a/server/storage/models/.gitignore b/server/storage/models/.gitignore
index 6ed579fa3..b78160e79 100644
--- a/server/storage/models/.gitignore
+++ b/server/storage/models/.gitignore
@@ -1,4 +1,5 @@
Xenova
downloaded/*
!downloaded/.placeholder
-openrouter
\ No newline at end of file
+openrouter
+apipie
\ No newline at end of file
diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json
index 6107331d5..b12fbf535 100644
--- a/server/swagger/openapi.json
+++ b/server/swagger/openapi.json
@@ -693,52 +693,6 @@
}
},
"/v1/admin/preferences": {
- "get": {
- "tags": [
- "Admin"
- ],
- "description": "Show all 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": {
- "settings": {
- "limit_user_messages": false,
- "message_limit": 10
- }
- }
- }
- }
- }
- },
- "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"
- }
- }
- },
"post": {
"tags": [
"Admin"
@@ -788,8 +742,7 @@
"content": {
"application/json": {
"example": {
- "limit_user_messages": true,
- "message_limit": 5
+ "support_email": "support@example.com"
}
}
}
@@ -2438,12 +2391,14 @@
}
},
"requestBody": {
- "description": "Optional userId associated with the thread",
+ "description": "Optional userId associated with the thread, thread slug and thread name",
"required": false,
"content": {
"application/json": {
"example": {
- "userId": 1
+ "userId": 1,
+ "name": "Name",
+ "slug": "thread-slug"
}
}
}
diff --git a/server/utils/AiProviders/apipie/index.js b/server/utils/AiProviders/apipie/index.js
new file mode 100644
index 000000000..acfd2b1e6
--- /dev/null
+++ b/server/utils/AiProviders/apipie/index.js
@@ -0,0 +1,336 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ handleDefaultStreamResponseV2,
+} = require("../../helpers/chat/responses");
+
+const { v4: uuidv4 } = require("uuid");
+const {
+ writeResponseChunk,
+ clientAbortedHandler,
+} = require("../../helpers/chat/responses");
+
+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", "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" }),
+ {}
+ );
+ }
+
+ 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,
+ ...chatHistory,
+ {
+ 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 this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ });
+
+ if (!result.hasOwnProperty("choices") || result.choices.length === 0)
+ return null;
+ return result.choices[0].message.content;
+ }
+
+ // APIPie says it supports streaming, but it does not work across all models and providers.
+ // Notably, it is not working for OpenRouter models at all.
+ // 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 streamRequest = await this.openai.chat.completions.create({
+ // model: this.model,
+ // stream: true,
+ // messages,
+ // temperature,
+ // });
+ // return streamRequest;
+ // }
+
+ 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 = () => 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);
+ resolve(fullText);
+ }
+ }
+ } catch (e) {
+ writeResponseChunk(response, {
+ uuid,
+ sources,
+ type: "abort",
+ textResponse: null,
+ close: true,
+ error: e.message,
+ });
+ response.removeListener("close", handleAbort);
+ resolve(fullText);
+ }
+ });
+ }
+
+ // 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);
+ }
+}
+
+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,
+ 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
index 2a293d053..98c6d5153 100644
--- a/server/utils/AiProviders/azureOpenAi/index.js
+++ b/server/utils/AiProviders/azureOpenAi/index.js
@@ -5,7 +5,7 @@ const {
} = require("../../helpers/chat/responses");
class AzureOpenAiLLM {
- constructor(embedder = null, _modelPreference = null) {
+ constructor(embedder = null, modelPreference = null) {
const { OpenAIClient, AzureKeyCredential } = require("@azure/openai");
if (!process.env.AZURE_OPENAI_ENDPOINT)
throw new Error("No Azure API endpoint was set.");
@@ -16,7 +16,7 @@ class AzureOpenAiLLM {
process.env.AZURE_OPENAI_ENDPOINT,
new AzureKeyCredential(process.env.AZURE_OPENAI_KEY)
);
- this.model = process.env.OPEN_MODEL_PREF;
+ this.model = modelPreference ?? process.env.OPEN_MODEL_PREF;
this.limits = {
history: this.promptWindowLimit() * 0.15,
system: this.promptWindowLimit() * 0.15,
diff --git a/server/utils/AiProviders/bedrock/index.js b/server/utils/AiProviders/bedrock/index.js
index ebff7ea29..c271f7297 100644
--- a/server/utils/AiProviders/bedrock/index.js
+++ b/server/utils/AiProviders/bedrock/index.js
@@ -7,6 +7,20 @@ const { NativeEmbedder } = require("../../EmbeddingEngines/native");
// Docs: https://js.langchain.com/v0.2/docs/integrations/chat/bedrock_converse
class AWSBedrockLLM {
+ /**
+ * These models do not support system prompts
+ * It is not explicitly stated but it is observed that they do not use the system prompt
+ * in their responses and will crash when a system prompt is provided.
+ * We can add more models to this list as we discover them or new models are added.
+ * We may want to extend this list or make a user-config if using custom bedrock models.
+ */
+ noSystemPromptModels = [
+ "amazon.titan-text-express-v1",
+ "amazon.titan-text-lite-v1",
+ "cohere.command-text-v14",
+ "cohere.command-light-text-v14",
+ ];
+
constructor(embedder = null, modelPreference = null) {
if (!process.env.AWS_BEDROCK_LLM_ACCESS_KEY_ID)
throw new Error("No AWS Bedrock LLM profile id was set.");
@@ -32,7 +46,7 @@ class AWSBedrockLLM {
#bedrockClient({ temperature = 0.7 }) {
const { ChatBedrockConverse } = require("@langchain/aws");
return new ChatBedrockConverse({
- model: process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE,
+ model: this.model,
region: process.env.AWS_BEDROCK_LLM_REGION,
credentials: {
accessKeyId: process.env.AWS_BEDROCK_LLM_ACCESS_KEY_ID,
@@ -59,6 +73,22 @@ class AWSBedrockLLM {
for (const chat of chats) {
if (!roleToMessageMap.hasOwnProperty(chat.role)) continue;
+
+ // When a model does not support system prompts, we need to handle it.
+ // We will add a new message that simulates the system prompt via a user message and AI response.
+ // This will allow the model to respond without crashing but we can still inject context.
+ if (
+ this.noSystemPromptModels.includes(this.model) &&
+ chat.role === "system"
+ ) {
+ this.#log(
+ `Model does not support system prompts! Simulating system prompt via Human/AI message pairs.`
+ );
+ langchainChats.push(new HumanMessage({ content: chat.content }));
+ langchainChats.push(new AIMessage({ content: "Okay." }));
+ continue;
+ }
+
const MessageClass = roleToMessageMap[chat.role];
langchainChats.push(new MessageClass({ content: chat.content }));
}
@@ -78,6 +108,10 @@ class AWSBedrockLLM {
);
}
+ #log(text, ...args) {
+ console.log(`\x1b[32m[AWSBedrock]\x1b[0m ${text}`, ...args);
+ }
+
streamingEnabled() {
return "streamGetChatCompletion" in this;
}
diff --git a/server/utils/AiProviders/groq/index.js b/server/utils/AiProviders/groq/index.js
index c176f1dca..d928e5e0d 100644
--- a/server/utils/AiProviders/groq/index.js
+++ b/server/utils/AiProviders/groq/index.js
@@ -37,6 +37,10 @@ class GroqLLM {
);
}
+ #log(text, ...args) {
+ console.log(`\x1b[32m[GroqAi]\x1b[0m ${text}`, ...args);
+ }
+
streamingEnabled() {
return "streamGetChatCompletion" in this;
}
@@ -53,17 +57,111 @@ class GroqLLM {
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.
+ */
+ #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
}) {
- const prompt = {
- role: "system",
- content: `${systemPrompt}${this.#appendContext(contextTexts)}`,
- };
- return [prompt, ...chatHistory, { role: "user", content: userPrompt }];
+ // 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 }) {
diff --git a/server/utils/AiProviders/lmStudio/index.js b/server/utils/AiProviders/lmStudio/index.js
index 6f0593b8c..f548adcbc 100644
--- a/server/utils/AiProviders/lmStudio/index.js
+++ b/server/utils/AiProviders/lmStudio/index.js
@@ -5,7 +5,7 @@ const {
// hybrid of openAi LLM chat completion for LMStudio
class LMStudioLLM {
- constructor(embedder = null, _modelPreference = null) {
+ constructor(embedder = null, modelPreference = null) {
if (!process.env.LMSTUDIO_BASE_PATH)
throw new Error("No LMStudio API Base Path was set.");
@@ -21,7 +21,10 @@ class LMStudioLLM {
// 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 {
throw new Error(e.message);
@@ -143,7 +155,7 @@ class OpenAiLLM {
model: this.model,
stream: true,
messages,
- temperature,
+ temperature: this.isO1Model ? 1 : temperature, // o1 models only accept temperature 1
});
return streamRequest;
}
diff --git a/server/utils/AiProviders/xai/index.js b/server/utils/AiProviders/xai/index.js
new file mode 100644
index 000000000..7a25760df
--- /dev/null
+++ b/server/utils/AiProviders/xai/index.js
@@ -0,0 +1,168 @@
+const { NativeEmbedder } = require("../../EmbeddingEngines/native");
+const {
+ handleDefaultStreamResponseV2,
+} = 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;
+ }
+
+ #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.xai[modelName] ?? 131_072;
+ }
+
+ promptWindowLimit() {
+ return MODEL_MAP.xai[this.model] ?? 131_072;
+ }
+
+ isValidChatCompletionModel(modelName = "") {
+ switch (modelName) {
+ case "grok-beta":
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * 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,
+ ...chatHistory,
+ {
+ 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 this.openai.chat.completions
+ .create({
+ model: this.model,
+ messages,
+ temperature,
+ })
+ .catch((e) => {
+ throw new Error(e.message);
+ });
+
+ if (!result.hasOwnProperty("choices") || result.choices.length === 0)
+ return null;
+ return result.choices[0].message.content;
+ }
+
+ 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 streamRequest = await this.openai.chat.completions.create({
+ model: this.model,
+ stream: true,
+ messages,
+ temperature,
+ });
+ return streamRequest;
+ }
+
+ 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/EmbeddingEngines/voyageAi/index.js b/server/utils/EmbeddingEngines/voyageAi/index.js
index 7f284fb49..f15272943 100644
--- a/server/utils/EmbeddingEngines/voyageAi/index.js
+++ b/server/utils/EmbeddingEngines/voyageAi/index.js
@@ -11,7 +11,7 @@ class VoyageAiEmbedder {
});
this.voyage = voyage;
- this.model = process.env.EMBEDDING_MODEL_PREF || "voyage-large-2-instruct";
+ this.model = process.env.EMBEDDING_MODEL_PREF || "voyage-3-lite";
// Limit of how many strings we can process in a single pass to stay with resource or network limits
this.batchSize = 128; // Voyage AI's limit per request is 128 https://docs.voyageai.com/docs/rate-limits#use-larger-batches
@@ -23,6 +23,8 @@ class VoyageAiEmbedder {
switch (this.model) {
case "voyage-finance-2":
case "voyage-multilingual-2":
+ case "voyage-3":
+ case "voyage-3-lite":
return 32_000;
case "voyage-large-2-instruct":
case "voyage-law-2":
diff --git a/server/utils/TextToSpeech/index.js b/server/utils/TextToSpeech/index.js
index 155fc9540..5ed5684de 100644
--- a/server/utils/TextToSpeech/index.js
+++ b/server/utils/TextToSpeech/index.js
@@ -7,6 +7,9 @@ function getTTSProvider() {
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!");
}
diff --git a/server/utils/TextToSpeech/openAiGeneric/index.js b/server/utils/TextToSpeech/openAiGeneric/index.js
new file mode 100644
index 000000000..df39e6348
--- /dev/null
+++ b/server/utils/TextToSpeech/openAiGeneric/index.js
@@ -0,0 +1,50 @@
+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_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.voice = process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL ?? "alloy";
+ }
+
+ #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: "tts-1",
+ 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/agents/aibitat/index.js b/server/utils/agents/aibitat/index.js
index 1d356f00a..24f027cff 100644
--- a/server/utils/agents/aibitat/index.js
+++ b/server/utils/agents/aibitat/index.js
@@ -756,7 +756,7 @@ ${this.getHistory({ to: route.to })
case "anthropic":
return new Providers.AnthropicProvider({ model: config.model });
case "lmstudio":
- return new Providers.LMStudioProvider({});
+ return new Providers.LMStudioProvider({ model: config.model });
case "ollama":
return new Providers.OllamaProvider({ model: config.model });
case "groq":
@@ -785,6 +785,12 @@ ${this.getHistory({ to: route.to })
return new Providers.FireworksAIProvider({ 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 });
default:
throw new Error(
diff --git a/server/utils/agents/aibitat/plugins/web-browsing.js b/server/utils/agents/aibitat/plugins/web-browsing.js
index 76849056e..31e06cab0 100644
--- a/server/utils/agents/aibitat/plugins/web-browsing.js
+++ b/server/utils/agents/aibitat/plugins/web-browsing.js
@@ -77,6 +77,9 @@ const webBrowsing = {
case "searxng-engine":
engine = "_searXNGEngine";
break;
+ case "tavily-search":
+ engine = "_tavilySearch";
+ break;
default:
engine = "_googleSearchEngine";
}
@@ -436,6 +439,59 @@ const webBrowsing = {
});
});
+ if (data.length === 0)
+ return `No information was found online for the search query.`;
+ this.super.introspect(
+ `${this.caller}: I found ${data.length} results - looking over them now.`
+ );
+ return JSON.stringify(data);
+ },
+ _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) => res.json())
+ .then((data) => {
+ return { response: data, error: null };
+ })
+ .catch((e) => {
+ 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.`;
this.super.introspect(
diff --git a/server/utils/agents/aibitat/providers/ai-provider.js b/server/utils/agents/aibitat/providers/ai-provider.js
index 3a144ec6c..c9925d1cd 100644
--- a/server/utils/agents/aibitat/providers/ai-provider.js
+++ b/server/utils/agents/aibitat/providers/ai-provider.js
@@ -130,6 +130,30 @@ class Provider {
apiKey: process.env.FIREWORKS_AI_LLM_API_KEY,
...config,
});
+ case "apipie":
+ return new ChatOpenAI({
+ configuration: {
+ baseURL: "https://apipie.ai/v1",
+ },
+ apiKey: process.env.APIPIE_LLM_API_KEY ?? null,
+ ...config,
+ });
+ case "deepseek":
+ return new ChatOpenAI({
+ configuration: {
+ baseURL: "https://api.deepseek.com/v1",
+ },
+ apiKey: process.env.DEEPSEEK_API_KEY ?? null,
+ ...config,
+ });
+ case "xai":
+ return new ChatOpenAI({
+ configuration: {
+ baseURL: "https://api.x.ai/v1",
+ },
+ apiKey: process.env.XAI_LLM_API_KEY ?? null,
+ ...config,
+ });
// OSS Model Runners
// case "anythingllm_ollama":
@@ -174,14 +198,15 @@ class Provider {
apiKey: process.env.TEXT_GEN_WEB_UI_API_KEY ?? "not-used",
...config,
});
- case "deepseek":
+ case "litellm":
return new ChatOpenAI({
configuration: {
- baseURL: "https://api.deepseek.com/v1",
+ baseURL: process.env.LITE_LLM_BASE_PATH,
},
- apiKey: process.env.DEEPSEEK_API_KEY ?? null,
+ apiKey: process.env.LITE_LLM_API_KEY ?? null,
...config,
});
+
default:
throw new Error(`Unsupported provider ${provider} for this task.`);
}
diff --git a/server/utils/agents/aibitat/providers/apipie.js b/server/utils/agents/aibitat/providers/apipie.js
new file mode 100644
index 000000000..4c6a3c8bf
--- /dev/null
+++ b/server/utils/agents/aibitat/providers/apipie.js
@@ -0,0 +1,116 @@
+const OpenAI = require("openai");
+const Provider = require("./ai-provider.js");
+const InheritMultiple = require("./helpers/classes.js");
+const UnTooled = require("./helpers/untooled.js");
+
+/**
+ * The agent provider for the OpenRouter provider.
+ */
+class ApiPieProvider extends InheritMultiple([Provider, UnTooled]) {
+ model;
+
+ constructor(config = {}) {
+ const { model = "openrouter/llama-3.1-8b-instruct" } = config;
+ super();
+ const client = new OpenAI({
+ baseURL: "https://apipie.ai/v1",
+ apiKey: process.env.APIPIE_LLM_API_KEY,
+ maxRetries: 3,
+ });
+
+ this._client = client;
+ this.model = model;
+ this.verbose = true;
+ }
+
+ get client() {
+ return this._client;
+ }
+
+ async #handleFunctionCallChat({ messages = [] }) {
+ return await this.client.chat.completions
+ .create({
+ model: this.model,
+ temperature: 0,
+ messages,
+ })
+ .then((result) => {
+ if (!result.hasOwnProperty("choices"))
+ throw new Error("ApiPie chat: No results!");
+ if (result.choices.length === 0)
+ throw new Error("ApiPie chat: No results length!");
+ return result.choices[0].message.content;
+ })
+ .catch((_) => {
+ return null;
+ });
+ }
+
+ /**
+ * Create a completion based on the received messages.
+ *
+ * @param messages A list of messages to send to the API.
+ * @param functions
+ * @returns The completion.
+ */
+ async complete(messages, functions = null) {
+ try {
+ let completion;
+ if (functions.length > 0) {
+ const { toolCall, text } = await this.functionCall(
+ messages,
+ functions,
+ this.#handleFunctionCallChat.bind(this)
+ );
+
+ if (toolCall !== null) {
+ this.providerLog(`Valid tool call found - running ${toolCall.name}.`);
+ this.deduplicator.trackRun(toolCall.name, toolCall.arguments);
+ return {
+ result: null,
+ functionCall: {
+ name: toolCall.name,
+ arguments: toolCall.arguments,
+ },
+ cost: 0,
+ };
+ }
+ completion = { content: text };
+ }
+
+ if (!completion?.content) {
+ this.providerLog(
+ "Will assume chat completion without tool call inputs."
+ );
+ const response = await this.client.chat.completions.create({
+ model: this.model,
+ messages: this.cleanMsgs(messages),
+ });
+ completion = response.choices[0].message;
+ }
+
+ // The UnTooled class inherited Deduplicator is mostly useful to prevent the agent
+ // from calling the exact same function over and over in a loop within a single chat exchange
+ // _but_ we should enable it to call previously used tools in a new chat interaction.
+ this.deduplicator.reset("runs");
+ return {
+ result: completion.content,
+ cost: 0,
+ };
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ /**
+ * Get the cost of the completion.
+ *
+ * @param _usage The completion to get the cost for.
+ * @returns The cost of the completion.
+ */
+ getCost(_usage) {
+ return 0;
+ }
+}
+
+module.exports = ApiPieProvider;
diff --git a/server/utils/agents/aibitat/providers/helpers/untooled.js b/server/utils/agents/aibitat/providers/helpers/untooled.js
index 11fbfec8b..774bb6915 100644
--- a/server/utils/agents/aibitat/providers/helpers/untooled.js
+++ b/server/utils/agents/aibitat/providers/helpers/untooled.js
@@ -33,7 +33,10 @@ ${JSON.stringify(def.parameters.properties, null, 4)}\n`;
if (Array.isArray(def.examples)) {
def.examples.forEach(({ prompt, call }) => {
- shotExample += `Query: "${prompt}"\nJSON: ${call}\n`;
+ shotExample += `Query: "${prompt}"\nJSON: ${JSON.stringify({
+ name: def.name,
+ arguments: safeJsonParse(call, {}),
+ })}\n`;
});
}
output += `${shotExample}-----------\n`;
diff --git a/server/utils/agents/aibitat/providers/index.js b/server/utils/agents/aibitat/providers/index.js
index 086e0ccf0..47e2d8716 100644
--- a/server/utils/agents/aibitat/providers/index.js
+++ b/server/utils/agents/aibitat/providers/index.js
@@ -15,6 +15,9 @@ const TextWebGenUiProvider = require("./textgenwebui.js");
const AWSBedrockProvider = require("./bedrock.js");
const FireworksAIProvider = require("./fireworksai.js");
const DeepSeekProvider = require("./deepseek.js");
+const LiteLLMProvider = require("./litellm.js");
+const ApiPieProvider = require("./apipie.js");
+const XAIProvider = require("./xai.js");
module.exports = {
OpenAIProvider,
@@ -34,4 +37,7 @@ module.exports = {
TextWebGenUiProvider,
AWSBedrockProvider,
FireworksAIProvider,
+ LiteLLMProvider,
+ ApiPieProvider,
+ XAIProvider,
};
diff --git a/server/utils/agents/aibitat/providers/litellm.js b/server/utils/agents/aibitat/providers/litellm.js
new file mode 100644
index 000000000..ad489c269
--- /dev/null
+++ b/server/utils/agents/aibitat/providers/litellm.js
@@ -0,0 +1,110 @@
+const OpenAI = require("openai");
+const Provider = require("./ai-provider.js");
+const InheritMultiple = require("./helpers/classes.js");
+const UnTooled = require("./helpers/untooled.js");
+
+/**
+ * The agent provider for LiteLLM.
+ */
+class LiteLLMProvider extends InheritMultiple([Provider, UnTooled]) {
+ model;
+
+ constructor(config = {}) {
+ super();
+ const { model = null } = config;
+ const client = new OpenAI({
+ baseURL: process.env.LITE_LLM_BASE_PATH,
+ apiKey: process.env.LITE_LLM_API_KEY ?? null,
+ maxRetries: 3,
+ });
+
+ this._client = client;
+ this.model = model || process.env.LITE_LLM_MODEL_PREF;
+ this.verbose = true;
+ }
+
+ get client() {
+ return this._client;
+ }
+
+ async #handleFunctionCallChat({ messages = [] }) {
+ return await this.client.chat.completions
+ .create({
+ model: this.model,
+ temperature: 0,
+ messages,
+ })
+ .then((result) => {
+ if (!result.hasOwnProperty("choices"))
+ throw new Error("LiteLLM chat: No results!");
+ if (result.choices.length === 0)
+ throw new Error("LiteLLM chat: No results length!");
+ return result.choices[0].message.content;
+ })
+ .catch((_) => {
+ return null;
+ });
+ }
+
+ /**
+ * Create a completion based on the received messages.
+ *
+ * @param messages A list of messages to send to the API.
+ * @param functions
+ * @returns The completion.
+ */
+ async complete(messages, functions = null) {
+ try {
+ let completion;
+ if (functions.length > 0) {
+ const { toolCall, text } = await this.functionCall(
+ messages,
+ functions,
+ this.#handleFunctionCallChat.bind(this)
+ );
+
+ if (toolCall !== null) {
+ this.providerLog(`Valid tool call found - running ${toolCall.name}.`);
+ this.deduplicator.trackRun(toolCall.name, toolCall.arguments);
+ return {
+ result: null,
+ functionCall: {
+ name: toolCall.name,
+ arguments: toolCall.arguments,
+ },
+ cost: 0,
+ };
+ }
+ completion = { content: text };
+ }
+
+ if (!completion?.content) {
+ this.providerLog(
+ "Will assume chat completion without tool call inputs."
+ );
+ const response = await this.client.chat.completions.create({
+ model: this.model,
+ messages: this.cleanMsgs(messages),
+ });
+ completion = response.choices[0].message;
+ }
+
+ // The UnTooled class inherited Deduplicator is mostly useful to prevent the agent
+ // from calling the exact same function over and over in a loop within a single chat exchange
+ // _but_ we should enable it to call previously used tools in a new chat interaction.
+ this.deduplicator.reset("runs");
+ return {
+ result: completion.content,
+ cost: 0,
+ };
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ getCost(_usage) {
+ return 0;
+ }
+}
+
+module.exports = LiteLLMProvider;
diff --git a/server/utils/agents/aibitat/providers/lmstudio.js b/server/utils/agents/aibitat/providers/lmstudio.js
index 258f2e291..c8f7c9108 100644
--- a/server/utils/agents/aibitat/providers/lmstudio.js
+++ b/server/utils/agents/aibitat/providers/lmstudio.js
@@ -9,9 +9,14 @@ const UnTooled = require("./helpers/untooled.js");
class LMStudioProvider extends InheritMultiple([Provider, UnTooled]) {
model;
- constructor(_config = {}) {
+ /**
+ *
+ * @param {{model?: string}} config
+ */
+ constructor(config = {}) {
super();
- const model = process.env.LMSTUDIO_MODEL_PREF || "Loaded from Chat UI";
+ const model =
+ config?.model || process.env.LMSTUDIO_MODEL_PREF || "Loaded from Chat UI";
const client = new OpenAI({
baseURL: process.env.LMSTUDIO_BASE_PATH?.replace(/\/+$/, ""), // here is the URL to your LMStudio instance
apiKey: null,
diff --git a/server/utils/agents/aibitat/providers/xai.js b/server/utils/agents/aibitat/providers/xai.js
new file mode 100644
index 000000000..9461d865f
--- /dev/null
+++ b/server/utils/agents/aibitat/providers/xai.js
@@ -0,0 +1,116 @@
+const OpenAI = require("openai");
+const Provider = require("./ai-provider.js");
+const InheritMultiple = require("./helpers/classes.js");
+const UnTooled = require("./helpers/untooled.js");
+
+/**
+ * The agent provider for the xAI provider.
+ */
+class XAIProvider extends InheritMultiple([Provider, UnTooled]) {
+ model;
+
+ constructor(config = {}) {
+ const { model = "grok-beta" } = config;
+ super();
+ const client = new OpenAI({
+ baseURL: "https://api.x.ai/v1",
+ apiKey: process.env.XAI_LLM_API_KEY,
+ maxRetries: 3,
+ });
+
+ this._client = client;
+ this.model = model;
+ this.verbose = true;
+ }
+
+ get client() {
+ return this._client;
+ }
+
+ async #handleFunctionCallChat({ messages = [] }) {
+ return await this.client.chat.completions
+ .create({
+ model: this.model,
+ temperature: 0,
+ messages,
+ })
+ .then((result) => {
+ if (!result.hasOwnProperty("choices"))
+ throw new Error("xAI chat: No results!");
+ if (result.choices.length === 0)
+ throw new Error("xAI chat: No results length!");
+ return result.choices[0].message.content;
+ })
+ .catch((_) => {
+ return null;
+ });
+ }
+
+ /**
+ * Create a completion based on the received messages.
+ *
+ * @param messages A list of messages to send to the API.
+ * @param functions
+ * @returns The completion.
+ */
+ async complete(messages, functions = null) {
+ try {
+ let completion;
+ if (functions.length > 0) {
+ const { toolCall, text } = await this.functionCall(
+ messages,
+ functions,
+ this.#handleFunctionCallChat.bind(this)
+ );
+
+ if (toolCall !== null) {
+ this.providerLog(`Valid tool call found - running ${toolCall.name}.`);
+ this.deduplicator.trackRun(toolCall.name, toolCall.arguments);
+ return {
+ result: null,
+ functionCall: {
+ name: toolCall.name,
+ arguments: toolCall.arguments,
+ },
+ cost: 0,
+ };
+ }
+ completion = { content: text };
+ }
+
+ if (!completion?.content) {
+ this.providerLog(
+ "Will assume chat completion without tool call inputs."
+ );
+ const response = await this.client.chat.completions.create({
+ model: this.model,
+ messages: this.cleanMsgs(messages),
+ });
+ completion = response.choices[0].message;
+ }
+
+ // The UnTooled class inherited Deduplicator is mostly useful to prevent the agent
+ // from calling the exact same function over and over in a loop within a single chat exchange
+ // _but_ we should enable it to call previously used tools in a new chat interaction.
+ this.deduplicator.reset("runs");
+ return {
+ result: completion.content,
+ cost: 0,
+ };
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ /**
+ * Get the cost of the completion.
+ *
+ * @param _usage The completion to get the cost for.
+ * @returns The cost of the completion.
+ */
+ getCost(_usage) {
+ return 0;
+ }
+}
+
+module.exports = XAIProvider;
diff --git a/server/utils/agents/ephemeral.js b/server/utils/agents/ephemeral.js
index 7b05b4adc..1ab2a988a 100644
--- a/server/utils/agents/ephemeral.js
+++ b/server/utils/agents/ephemeral.js
@@ -99,30 +99,69 @@ class EphemeralAgentHandler extends AgentHandler {
}
}
+ /**
+ * Attempts to find a fallback provider and model to use if the workspace
+ * does not have an explicit `agentProvider` and `agentModel` set.
+ * 1. Fallback to the workspace `chatProvider` and `chatModel` if they exist.
+ * 2. Fallback to the system `LLM_PROVIDER` and try to load the the associated default model via ENV params or a base available model.
+ * 3. Otherwise, return null - will likely throw an error the user can act on.
+ * @returns {object|null} - An object with provider and model keys.
+ */
+ #getFallbackProvider() {
+ // First, fallback to the workspace chat provider and model if they exist
+ if (this.#workspace.chatProvider && this.#workspace.chatModel) {
+ return {
+ provider: this.#workspace.chatProvider,
+ model: this.#workspace.chatModel,
+ };
+ }
+
+ // If workspace does not have chat provider and model fallback
+ // to system provider and try to load provider default model
+ const systemProvider = process.env.LLM_PROVIDER;
+ const systemModel = this.providerDefault(systemProvider);
+ if (systemProvider && systemModel) {
+ return {
+ provider: systemProvider,
+ model: systemModel,
+ };
+ }
+
+ return null;
+ }
+
/**
* Finds or assumes the model preference value to use for API calls.
* If multi-model loading is supported, we use their agent model selection of the workspace
* If not supported, we attempt to fallback to the system provider value for the LLM preference
* and if that fails - we assume a reasonable base model to exist.
- * @returns {string} the model preference value to use in API calls
+ * @returns {string|null} the model preference value to use in API calls
*/
#fetchModel() {
- if (!Object.keys(this.noProviderModelDefault).includes(this.provider))
- return this.#workspace.agentModel || this.providerDefault();
+ // Provider was not explicitly set for workspace, so we are going to run our fallback logic
+ // that will set a provider and model for us to use.
+ if (!this.provider) {
+ const fallback = this.#getFallbackProvider();
+ if (!fallback) throw new Error("No valid provider found for the agent.");
+ this.provider = fallback.provider; // re-set the provider to the fallback provider so it is not null.
+ return fallback.model; // set its defined model based on fallback logic.
+ }
- // Provider has no reliable default (cant load many models) - so we need to look at system
- // for the model param.
- const sysModelKey = this.noProviderModelDefault[this.provider];
- if (!!sysModelKey)
- return process.env[sysModelKey] ?? this.providerDefault();
+ // The provider was explicitly set, so check if the workspace has an agent model set.
+ if (this.invocation.workspace.agentModel)
+ return this.invocation.workspace.agentModel;
- // If all else fails - look at the provider default list
+ // Otherwise, we have no model to use - so guess a default model to use via the provider
+ // and it's system ENV params and if that fails - we return either a base model or null.
return this.providerDefault();
}
#providerSetupAndCheck() {
- this.provider = this.#workspace.agentProvider;
+ this.provider = this.#workspace.agentProvider ?? null;
this.model = this.#fetchModel();
+
+ if (!this.provider)
+ throw new Error("No valid provider found for the agent.");
this.log(`Start ${this.#invocationUUID}::${this.provider}:${this.model}`);
this.checkSetup();
}
diff --git a/server/utils/agents/index.js b/server/utils/agents/index.js
index 3936f9388..fd7d06e8b 100644
--- a/server/utils/agents/index.js
+++ b/server/utils/agents/index.js
@@ -11,13 +11,6 @@ const ImportedPlugin = require("./imported");
class AgentHandler {
#invocationUUID;
#funcsToLoad = [];
- noProviderModelDefault = {
- azure: "OPEN_MODEL_PREF",
- lmstudio: "LMSTUDIO_MODEL_PREF",
- textgenwebui: null, // does not even use `model` in API req
- "generic-openai": "GENERIC_OPEN_AI_MODEL_PREF",
- bedrock: "AWS_BEDROCK_LLM_MODEL_PREFERENCE",
- };
invocation = null;
aibitat = null;
channel = null;
@@ -166,6 +159,20 @@ class AgentHandler {
if (!process.env.DEEPSEEK_API_KEY)
throw new Error("DeepSeek API Key must be provided to use agents.");
break;
+ case "litellm":
+ if (!process.env.LITE_LLM_BASE_PATH)
+ throw new Error(
+ "LiteLLM API base path and key must be provided to use agents."
+ );
+ break;
+ case "apipie":
+ if (!process.env.APIPIE_LLM_API_KEY)
+ throw new Error("ApiPie API Key must be provided to use agents.");
+ break;
+ case "xai":
+ if (!process.env.XAI_LLM_API_KEY)
+ throw new Error("xAI API Key must be provided to use agents.");
+ break;
default:
throw new Error(
@@ -174,49 +181,72 @@ class AgentHandler {
}
}
+ /**
+ * Finds the default model for a given provider. If no default model is set for it's associated ENV then
+ * it will return a reasonable base model for the provider if one exists.
+ * @param {string} provider - The provider to find the default model for.
+ * @returns {string|null} The default model for the provider.
+ */
providerDefault(provider = this.provider) {
switch (provider) {
case "openai":
- return "gpt-4o";
+ return process.env.OPEN_MODEL_PREF ?? "gpt-4o";
case "anthropic":
- return "claude-3-sonnet-20240229";
+ return process.env.ANTHROPIC_MODEL_PREF ?? "claude-3-sonnet-20240229";
case "lmstudio":
- return "server-default";
+ return process.env.LMSTUDIO_MODEL_PREF ?? "server-default";
case "ollama":
- return "llama3:latest";
+ return process.env.OLLAMA_MODEL_PREF ?? "llama3:latest";
case "groq":
- return "llama3-70b-8192";
+ return process.env.GROQ_MODEL_PREF ?? "llama3-70b-8192";
case "togetherai":
- return "mistralai/Mixtral-8x7B-Instruct-v0.1";
+ return (
+ process.env.TOGETHER_AI_MODEL_PREF ??
+ "mistralai/Mixtral-8x7B-Instruct-v0.1"
+ );
case "azure":
- return "gpt-3.5-turbo";
+ return null;
case "koboldcpp":
- return null;
+ return process.env.KOBOLD_CPP_MODEL_PREF ?? null;
case "gemini":
- return "gemini-pro";
+ return process.env.GEMINI_MODEL_PREF ?? "gemini-pro";
case "localai":
- return null;
+ return process.env.LOCAL_AI_MODEL_PREF ?? null;
case "openrouter":
- return "openrouter/auto";
+ return process.env.OPENROUTER_MODEL_PREF ?? "openrouter/auto";
case "mistral":
- return "mistral-medium";
+ return process.env.MISTRAL_MODEL_PREF ?? "mistral-medium";
case "generic-openai":
- return null;
+ return process.env.GENERIC_OPEN_AI_MODEL_PREF ?? null;
case "perplexity":
- return "sonar-small-online";
+ return process.env.PERPLEXITY_MODEL_PREF ?? "sonar-small-online";
case "textgenwebui":
return null;
case "bedrock":
- return null;
+ return process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE ?? null;
case "fireworksai":
- return null;
+ return process.env.FIREWORKS_AI_LLM_MODEL_PREF ?? null;
case "deepseek":
- return "deepseek-chat";
+ return process.env.DEEPSEEK_MODEL_PREF ?? "deepseek-chat";
+ case "litellm":
+ return process.env.LITE_LLM_MODEL_PREF ?? null;
+ case "apipie":
+ return process.env.APIPIE_LLM_MODEL_PREF ?? null;
+ case "xai":
+ return process.env.XAI_LLM_MODEL_PREF ?? "grok-beta";
default:
- return "unknown";
+ return null;
}
}
+ /**
+ * Attempts to find a fallback provider and model to use if the workspace
+ * does not have an explicit `agentProvider` and `agentModel` set.
+ * 1. Fallback to the workspace `chatProvider` and `chatModel` if they exist.
+ * 2. Fallback to the system `LLM_PROVIDER` and try to load the the associated default model via ENV params or a base available model.
+ * 3. Otherwise, return null - will likely throw an error the user can act on.
+ * @returns {object|null} - An object with provider and model keys.
+ */
#getFallbackProvider() {
// First, fallback to the workspace chat provider and model if they exist
if (
@@ -248,7 +278,7 @@ class AgentHandler {
* If multi-model loading is supported, we use their agent model selection of the workspace
* If not supported, we attempt to fallback to the system provider value for the LLM preference
* and if that fails - we assume a reasonable base model to exist.
- * @returns {string} the model preference value to use in API calls
+ * @returns {string|null} the model preference value to use in API calls
*/
#fetchModel() {
// Provider was not explicitly set for workspace, so we are going to run our fallback logic
@@ -261,21 +291,11 @@ class AgentHandler {
}
// The provider was explicitly set, so check if the workspace has an agent model set.
- if (this.invocation.workspace.agentModel) {
+ if (this.invocation.workspace.agentModel)
return this.invocation.workspace.agentModel;
- }
- // If the provider we are using is not supported or does not support multi-model loading
- // then we use the default model for the provider.
- if (!Object.keys(this.noProviderModelDefault).includes(this.provider)) {
- return this.providerDefault();
- }
-
- // Load the model from the system environment variable for providers with no multi-model loading.
- const sysModelKey = this.noProviderModelDefault[this.provider];
- if (sysModelKey) return process.env[sysModelKey] ?? this.providerDefault();
-
- // Otherwise, we have no model to use - so guess a default model to use.
+ // Otherwise, we have no model to use - so guess a default model to use via the provider
+ // and it's system ENV params and if that fails - we return either a base model or null.
return this.providerDefault();
}
@@ -285,7 +305,6 @@ class AgentHandler {
if (!this.provider)
throw new Error("No valid provider found for the agent.");
-
this.log(`Start ${this.#invocationUUID}::${this.provider}:${this.model}`);
this.checkSetup();
}
diff --git a/server/utils/chats/embed.js b/server/utils/chats/embed.js
index 810806059..b4d1a03fb 100644
--- a/server/utils/chats/embed.js
+++ b/server/utils/chats/embed.js
@@ -60,8 +60,7 @@ async function streamChatWithForEmbed(
const { rawHistory, chatHistory } = await recentEmbedChatHistory(
sessionId,
embed,
- messageLimit,
- chatMode
+ messageLimit
);
// See stream.js comment for more information on this implementation.
@@ -113,16 +112,27 @@ async function streamChatWithForEmbed(
return;
}
- contextTexts = [...contextTexts, ...vectorSearchResults.contextTexts];
+ const { fillSourceWindow } = require("../helpers/chat");
+ const filledSources = fillSourceWindow({
+ nDocs: embed.workspace?.topN || 4,
+ searchResults: vectorSearchResults.sources,
+ history: rawHistory,
+ filterIdentifiers: pinnedDocIdentifiers,
+ });
+
+ // Why does contextTexts get all the info, but sources only get current search?
+ // This is to give the ability of the LLM to "comprehend" a contextual response without
+ // populating the Citations under a response with documents the user "thinks" are irrelevant
+ // due to how we manage backfilling of the context to keep chats with the LLM more correct in responses.
+ // If a past citation was used to answer the question - that is visible in the history so it logically makes sense
+ // and does not appear to the user that a new response used information that is otherwise irrelevant for a given prompt.
+ // TLDR; reduces GitHub issues for "LLM citing document that has no answer in it" while keep answers highly accurate.
+ contextTexts = [...contextTexts, ...filledSources.contextTexts];
sources = [...sources, ...vectorSearchResults.sources];
- // If in query mode and no sources are found, do not
+ // If in query mode and no sources are found in current search or backfilled from history, do not
// let the LLM try to hallucinate a response or use general knowledge
- if (
- chatMode === "query" &&
- sources.length === 0 &&
- pinnedDocIdentifiers.length === 0
- ) {
+ if (chatMode === "query" && contextTexts.length === 0) {
writeResponseChunk(response, {
id: uuid,
type: "textResponse",
@@ -178,7 +188,7 @@ async function streamChatWithForEmbed(
await EmbedChats.new({
embedId: embed.id,
prompt: message,
- response: { text: completeText, type: chatMode },
+ response: { text: completeText, type: chatMode, sources },
connection_information: response.locals.connection
? {
...response.locals.connection,
@@ -190,15 +200,13 @@ async function streamChatWithForEmbed(
return;
}
-// On query we don't return message history. All other chat modes and when chatting
-// with no embeddings we return history.
-async function recentEmbedChatHistory(
- sessionId,
- embed,
- messageLimit = 20,
- chatMode = null
-) {
- if (chatMode === "query") return { rawHistory: [], chatHistory: [] };
+/**
+ * @param {string} sessionId the session id of the user from embed widget
+ * @param {Object} embed the embed config object
+ * @param {Number} messageLimit the number of messages to return
+ * @returns {Promise<{rawHistory: import("@prisma/client").embed_chats[], chatHistory: {role: string, content: string}[]}>
+ */
+async function recentEmbedChatHistory(sessionId, embed, messageLimit = 20) {
const rawHistory = (
await EmbedChats.forEmbedByUser(embed.id, sessionId, messageLimit, {
id: "desc",
diff --git a/server/utils/helpers/customModels.js b/server/utils/helpers/customModels.js
index f061d35ff..7ccbf13c7 100644
--- a/server/utils/helpers/customModels.js
+++ b/server/utils/helpers/customModels.js
@@ -1,4 +1,5 @@
const { fetchOpenRouterModels } = require("../AiProviders/openRouter");
+const { fetchApiPieModels } = require("../AiProviders/apipie");
const { perplexityModels } = require("../AiProviders/perplexity");
const { togetherAiModels } = require("../AiProviders/togetherAi");
const { fireworksAiModels } = require("../AiProviders/fireworksAi");
@@ -19,6 +20,8 @@ const SUPPORT_CUSTOM_MODELS = [
"elevenlabs-tts",
"groq",
"deepseek",
+ "apipie",
+ "xai",
];
async function getCustomModels(provider = "", apiKey = null, basePath = null) {
@@ -56,6 +59,10 @@ async function getCustomModels(provider = "", apiKey = null, basePath = null) {
return await getGroqAiModels(apiKey);
case "deepseek":
return await getDeepSeekModels(apiKey);
+ case "apipie":
+ return await getAPIPieModels(apiKey);
+ case "xai":
+ return await getXAIModels(apiKey);
default:
return { models: [], error: "Invalid provider for custom models" };
}
@@ -124,7 +131,7 @@ async function openAiModels(apiKey = null) {
});
const gpts = allModels
- .filter((model) => model.id.startsWith("gpt"))
+ .filter((model) => model.id.startsWith("gpt") || model.id.startsWith("o1"))
.filter(
(model) => !model.id.includes("vision") && !model.id.includes("instruct")
)
@@ -355,6 +362,21 @@ async function getOpenRouterModels() {
return { models, error: null };
}
+async function getAPIPieModels(apiKey = null) {
+ const knownModels = await fetchApiPieModels(apiKey);
+ if (!Object.keys(knownModels).length === 0)
+ return { models: [], error: null };
+
+ const models = Object.values(knownModels).map((model) => {
+ return {
+ id: model.id,
+ organization: model.organization,
+ name: model.name,
+ };
+ });
+ return { models, error: null };
+}
+
async function getMistralModels(apiKey = null) {
const { OpenAI: OpenAIApi } = require("openai");
const openai = new OpenAIApi({
@@ -447,6 +469,36 @@ async function getDeepSeekModels(apiKey = null) {
return { models, error: null };
}
+async function getXAIModels(_apiKey = null) {
+ const { OpenAI: OpenAIApi } = require("openai");
+ const apiKey =
+ _apiKey === true
+ ? process.env.XAI_LLM_API_KEY
+ : _apiKey || process.env.XAI_LLM_API_KEY || null;
+ const openai = new OpenAIApi({
+ baseURL: "https://api.x.ai/v1",
+ apiKey,
+ });
+ const models = await openai.models
+ .list()
+ .then((results) => results.data)
+ .catch((e) => {
+ console.error(`XAI:listModels`, e.message);
+ return [
+ {
+ created: 1725148800,
+ id: "grok-beta",
+ object: "model",
+ owned_by: "xai",
+ },
+ ];
+ });
+
+ // Api Key was successful so lets save it for future uses
+ if (models.length > 0 && !!apiKey) process.env.XAI_LLM_API_KEY = apiKey;
+ return { models, error: null };
+}
+
module.exports = {
getCustomModels,
};
diff --git a/server/utils/helpers/index.js b/server/utils/helpers/index.js
index 6f2dd79d4..84f971cc6 100644
--- a/server/utils/helpers/index.js
+++ b/server/utils/helpers/index.js
@@ -162,6 +162,12 @@ function getLLMProvider({ provider = null, model = null } = {}) {
case "deepseek":
const { DeepSeekLLM } = require("../AiProviders/deepseek");
return new DeepSeekLLM(embedder, model);
+ case "apipie":
+ const { ApiPieLLM } = require("../AiProviders/apipie");
+ return new ApiPieLLM(embedder, model);
+ case "xai":
+ const { XAiLLM } = require("../AiProviders/xai");
+ return new XAiLLM(embedder, model);
default:
throw new Error(
`ENV: No valid LLM_PROVIDER value found in environment! Using ${process.env.LLM_PROVIDER}`
@@ -285,6 +291,15 @@ function getLLMProviderClass({ provider = null } = {}) {
case "bedrock":
const { AWSBedrockLLM } = require("../AiProviders/bedrock");
return AWSBedrockLLM;
+ case "deepseek":
+ const { DeepSeekLLM } = require("../AiProviders/deepseek");
+ return DeepSeekLLM;
+ case "apipie":
+ const { ApiPieLLM } = require("../AiProviders/apipie");
+ return ApiPieLLM;
+ case "xai":
+ const { XAiLLM } = require("../AiProviders/xai");
+ return XAiLLM;
default:
return null;
}
diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js
index f03cfa8fe..038f6d903 100644
--- a/server/utils/helpers/updateENV.js
+++ b/server/utils/helpers/updateENV.js
@@ -469,6 +469,10 @@ const KEY_MAPPING = {
envKey: "AGENT_SEARXNG_API_URL",
checks: [],
},
+ AgentTavilyApiKey: {
+ envKey: "AGENT_TAVILY_API_KEY",
+ checks: [],
+ },
// TTS/STT Integration ENVS
TextToSpeechProvider: {
@@ -502,6 +506,20 @@ const KEY_MAPPING = {
checks: [],
},
+ // OpenAI Generic TTS
+ TTSOpenAICompatibleKey: {
+ envKey: "TTS_OPEN_AI_COMPATIBLE_KEY",
+ checks: [],
+ },
+ TTSOpenAICompatibleVoiceModel: {
+ envKey: "TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL",
+ checks: [isNotEmpty],
+ },
+ TTSOpenAICompatibleEndpoint: {
+ envKey: "TTS_OPEN_AI_COMPATIBLE_ENDPOINT",
+ checks: [isValidURL],
+ },
+
// DeepSeek Options
DeepSeekApiKey: {
envKey: "DEEPSEEK_API_KEY",
@@ -511,6 +529,26 @@ const KEY_MAPPING = {
envKey: "DEEPSEEK_MODEL_PREF",
checks: [isNotEmpty],
},
+
+ // APIPie Options
+ ApipieLLMApiKey: {
+ envKey: "APIPIE_LLM_API_KEY",
+ checks: [isNotEmpty],
+ },
+ ApipieLLMModelPref: {
+ envKey: "APIPIE_LLM_MODEL_PREF",
+ checks: [isNotEmpty],
+ },
+
+ // xAI Options
+ XAIApiKey: {
+ envKey: "XAI_LLM_API_KEY",
+ checks: [isNotEmpty],
+ },
+ XAIModelPref: {
+ envKey: "XAI_LLM_MODEL_PREF",
+ checks: [isNotEmpty],
+ },
};
function isNotEmpty(input = "") {
@@ -575,6 +613,7 @@ function supportedTTSProvider(input = "") {
"openai",
"elevenlabs",
"piper_local",
+ "generic-openai",
].includes(input);
return validSelection ? null : `${input} is not a valid TTS provider.`;
}
@@ -613,6 +652,8 @@ function supportedLLM(input = "") {
"generic-openai",
"bedrock",
"deepseek",
+ "apipie",
+ "xai",
].includes(input);
return validSelection ? null : `${input} is not a valid LLM provider.`;
}
@@ -856,6 +897,8 @@ function dumpENV() {
"ENABLE_HTTPS",
"HTTPS_CERT_PATH",
"HTTPS_KEY_PATH",
+ // Other Configuration Keys
+ "DISABLE_VIEW_CHAT_HISTORY",
];
// Simple sanitization of each value to prevent ENV injection via newline or quote escaping.
diff --git a/server/utils/middleware/chatHistoryViewable.js b/server/utils/middleware/chatHistoryViewable.js
new file mode 100644
index 000000000..aa9534264
--- /dev/null
+++ b/server/utils/middleware/chatHistoryViewable.js
@@ -0,0 +1,18 @@
+/**
+ * A simple middleware that validates that the chat history is viewable.
+ * via the `DISABLE_VIEW_CHAT_HISTORY` environment variable being set AT ALL.
+ * @param {Request} request - The request object.
+ * @param {Response} response - The response object.
+ * @param {NextFunction} next - The next function.
+ */
+function chatHistoryViewable(_request, response, next) {
+ if ("DISABLE_VIEW_CHAT_HISTORY" in process.env)
+ return response
+ .status(422)
+ .send("This feature has been disabled by the administrator.");
+ next();
+}
+
+module.exports = {
+ chatHistoryViewable,
+};