From 226802d35a1d156ba803e917cff8495f40ee8c0c Mon Sep 17 00:00:00 2001 From: Chaser Huang Date: Wed, 17 Sep 2025 23:53:41 -0400 Subject: [PATCH 01/15] API request delay for Generic OpenAI embedding engine (#4317) * Add ENV to configure api request delay for generic open ai embedding engine * yarn lint formatting * refactor --------- Co-authored-by: timothycarambat --- docker/.env.example | 1 + server/.env.example | 1 + .../EmbeddingEngines/genericOpenAi/index.js | 105 +++++++++--------- 3 files changed, 57 insertions(+), 50 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index f0fe46d1..421d0536 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -195,6 +195,7 @@ GID='1000' # EMBEDDING_BASE_PATH='http://127.0.0.1:4000' # GENERIC_OPEN_AI_EMBEDDING_API_KEY='sk-123abc' # GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS=500 +# GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS=1000 # EMBEDDING_ENGINE='gemini' # GEMINI_EMBEDDING_API_KEY= diff --git a/server/.env.example b/server/.env.example index e1f5ebfd..c60319ab 100644 --- a/server/.env.example +++ b/server/.env.example @@ -194,6 +194,7 @@ SIG_SALT='salt' # Please generate random string at least 32 chars long. # EMBEDDING_BASE_PATH='http://127.0.0.1:4000' # GENERIC_OPEN_AI_EMBEDDING_API_KEY='sk-123abc' # GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS=500 +# GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS=1000 # EMBEDDING_ENGINE='gemini' # GEMINI_EMBEDDING_API_KEY= diff --git a/server/utils/EmbeddingEngines/genericOpenAi/index.js b/server/utils/EmbeddingEngines/genericOpenAi/index.js index e88538f4..a8a3ac1a 100644 --- a/server/utils/EmbeddingEngines/genericOpenAi/index.js +++ b/server/utils/EmbeddingEngines/genericOpenAi/index.js @@ -28,6 +28,35 @@ class GenericOpenAiEmbedder { console.log(`\x1b[36m[GenericOpenAiEmbedder]\x1b[0m ${text}`, ...args); } + /** + * returns the `GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS` env variable as a number or null if the env variable is not set or is not a number. + * The minimum delay is 500ms. + * + * For some implementation this is necessary to avoid 429 errors due to rate limiting or + * hardware limitations where a single-threaded process is not able to handle the requests fast enough. + * @returns {number} + */ + get apiRequestDelay() { + if (!("GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS" in process.env)) return null; + if (isNaN(Number(process.env.GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS))) + return null; + const delayTimeout = Number( + process.env.GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS + ); + if (delayTimeout < 500) return 500; // minimum delay of 500ms + return delayTimeout; + } + + /** + * runs the delay if it is set and valid. + * @returns {Promise} + */ + async runDelay() { + if (!this.apiRequestDelay) return; + this.log(`Delaying new batch request for ${this.apiRequestDelay}ms`); + await new Promise((resolve) => setTimeout(resolve, this.apiRequestDelay)); + } + /** * returns the `GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS` env variable as a number * or 500 if the env variable is not set or is not a number. @@ -52,62 +81,38 @@ class GenericOpenAiEmbedder { async embedChunks(textChunks = []) { // Because there is a hard POST limit on how many chunks can be sent at once to OpenAI (~8mb) - // we concurrently execute each max batch of text chunks possible. + // we sequentially execute each max batch of text chunks possible. // Refer to constructor maxConcurrentChunks for more info. - const embeddingRequests = []; + const allResults = []; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { - embeddingRequests.push( - new Promise((resolve) => { - this.openai.embeddings - .create({ - model: this.model, - input: chunk, - }) - .then((result) => { - resolve({ data: result?.data, error: null }); - }) - .catch((e) => { - e.type = - e?.response?.data?.error?.code || - e?.response?.status || - "failed_to_embed"; - e.message = e?.response?.data?.error?.message || e.message; - resolve({ data: [], error: e }); - }); - }) - ); - } + const { data = [], error = null } = await new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((result) => resolve({ data: result?.data, error: null })) + .catch((e) => { + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }); - const { data = [], error = null } = await Promise.all( - embeddingRequests - ).then((results) => { // If any errors were returned from OpenAI abort the entire sequence because the embeddings // will be incomplete. - const errors = results - .filter((res) => !!res.error) - .map((res) => res.error) - .flat(); - if (errors.length > 0) { - let uniqueErrors = new Set(); - errors.map((error) => - uniqueErrors.add(`[${error.type}]: ${error.message}`) - ); + if (error) + throw new Error(`GenericOpenAI Failed to embed: ${error.message}`); + allResults.push(...(data || [])); + if (this.apiRequestDelay) await this.runDelay(); + } - return { - data: [], - error: Array.from(uniqueErrors).join(", "), - }; - } - return { - data: results.map((res) => res?.data || []).flat(), - error: null, - }; - }); - - if (!!error) throw new Error(`GenericOpenAI Failed to embed: ${error}`); - return data.length > 0 && - data.every((embd) => embd.hasOwnProperty("embedding")) - ? data.map((embd) => embd.embedding) + return allResults.length > 0 && + allResults.every((embd) => embd.hasOwnProperty("embedding")) + ? allResults.map((embd) => embd.embedding) : null; } } From 01a3cc92d05ee82a15902bcd57d53093d3a11d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Krist=C3=B3f?= <9765439+MateKristof@users.noreply.github.com> Date: Thu, 18 Sep 2025 06:14:18 +0200 Subject: [PATCH 02/15] Enhanced Chat Embed History View (#4281) * Enhanced Chat Embed History View * Robust Markdown Rendering Improved "Thinking" View * feat: Improve markdown rendering in chat embed history * update ui for show/hide thoughts in embed chat history * refactor -always show thoughts if available * patch unused imports and use safeJsonParse * update fallback for loading state to always reset --------- Co-authored-by: Timothy Carambat Co-authored-by: shatfield4 --- .../EmbedChats/ChatRow/index.jsx | 20 +++-- .../EmbedChats/MarkdownRenderer.jsx | 87 +++++++++++++++++++ .../ChatEmbedWidgets/EmbedChats/index.jsx | 18 ++-- 3 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/MarkdownRenderer.jsx diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/ChatRow/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/ChatRow/index.jsx index 115b09a9..bf50e61f 100644 --- a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/ChatRow/index.jsx +++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/ChatRow/index.jsx @@ -1,9 +1,11 @@ import truncate from "truncate"; -import { X, Trash, LinkSimple } from "@phosphor-icons/react"; +import { X } from "@phosphor-icons/react"; import ModalWrapper from "@/components/ModalWrapper"; import { useModal } from "@/hooks/useModal"; import paths from "@/utils/paths"; import Embed from "@/models/embed"; +import MarkdownRenderer from "../MarkdownRenderer"; +import { safeJsonParse } from "@/utils/request"; export default function ChatRow({ chat, onDelete }) { const { @@ -83,7 +85,11 @@ export default function ChatRow({ chat, onDelete }) { + } closeModal={closeResponseModal} /> @@ -118,9 +124,9 @@ const TextPreview = ({ text, closeModal }) => {
-
+          
{text} -
+
@@ -132,11 +138,7 @@ const ConnectionDetails = ({ verbose = false, connection_information, }) => { - let details = {}; - try { - details = JSON.parse(connection_information); - } catch {} - + const details = safeJsonParse(connection_information, {}); if (Object.keys(details).length === 0) return null; if (verbose) { diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/MarkdownRenderer.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/MarkdownRenderer.jsx new file mode 100644 index 00000000..11b4ca51 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/MarkdownRenderer.jsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import MarkdownIt from "markdown-it"; +import { CaretDown } from "@phosphor-icons/react"; +import "highlight.js/styles/github-dark.css"; +import DOMPurify from "@/utils/chat/purify"; + +const md = new MarkdownIt({ + html: true, + breaks: true, + highlight: function (str, lang) { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang }).value; + } catch (__) {} + } + return ""; // use external default escaping + }, +}); + +const ThoughtBubble = ({ thought }) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!thought) return null; + + const cleanThought = thought.replace(/<\/?think>/g, "").trim(); + if (!cleanThought) return null; + + return ( +
+
setIsExpanded(!isExpanded)} + className="cursor-pointer flex items-center gap-x-2 text-theme-text-secondary hover:text-theme-text-primary transition-colors mb-2" + > + + View thoughts +
+ {isExpanded && ( +
+
+ {cleanThought} +
+
+ )} +
+ ); +}; + +function parseContent(content) { + const parts = []; + let lastIndex = 0; + content.replace(/([^]*?)<\/think>/g, (match, thinkContent, offset) => { + if (offset > lastIndex) { + parts.push({ type: "normal", text: content.slice(lastIndex, offset) }); + } + parts.push({ type: "think", text: thinkContent }); + lastIndex = offset + match.length; + }); + if (lastIndex < content.length) { + parts.push({ type: "normal", text: content.slice(lastIndex) }); + } + return parts; +} + +export default function MarkdownRenderer({ content }) { + if (!content) return null; + + const parts = parseContent(content); + return ( +
+ {parts.map((part, index) => { + const html = md.render(part.text); + if (part.type === "think") + return ; + return ( +
+ ); + })} +
+ ); +} diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/index.jsx index 154094e2..cd242422 100644 --- a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/index.jsx +++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedChats/index.jsx @@ -55,6 +55,7 @@ export default function EmbedChatsView() { const query = useQuery(); const [offset, setOffset] = useState(Number(query.get("offset") || 0)); const [canNext, setCanNext] = useState(false); + const [showThinking, setShowThinking] = useState(true); const handleDumpChats = async (exportType) => { const chats = await System.exportChats(exportType, "embed"); @@ -92,10 +93,15 @@ export default function EmbedChatsView() { useEffect(() => { async function fetchChats() { - const { chats: _chats, hasPages = false } = await Embed.chats(offset); - setChats(_chats); - setCanNext(hasPages); - setLoading(false); + setLoading(true); + await Embed.chats(offset) + .then(({ chats: _chats, hasPages = false }) => { + setChats(_chats); + setCanNext(hasPages); + }) + .finally(() => { + setLoading(false); + }); } fetchChats(); }, [offset]); @@ -211,7 +217,7 @@ export default function EmbedChatsView() { : "bg-theme-bg-secondary text-theme-text-primary hover:bg-theme-hover" }`} > - {t("embed-chats.previous")} + {t("common.previous")}
)} From f0cdea4e35825cd7f31a52b766b2312aec6e2bc4 Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Thu, 18 Sep 2025 19:16:52 -0700 Subject: [PATCH 03/15] Ignore hasOwnProperty linting errors (#4406) ignore hasOwnProperty linting errors --- eslint.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.js b/eslint.config.js index b6ef861c..861ff636 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -46,6 +46,7 @@ export default [ "no-undef": "warn", "no-empty": "warn", "no-extra-boolean-cast": "warn", + "no-prototype-builtins": "off", "prettier/prettier": "warn" } }, From 1209606d9a8b599fd7ea21bd168cc9affe4b4ad4 Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Thu, 18 Sep 2025 21:15:19 -0700 Subject: [PATCH 04/15] Migrate OpenAI LLM provider to use Responses API (#4404) * migrate openai llm provider to use responses api * add back image support * dont recalc tokens from OpenAI since we get metrics back --------- Co-authored-by: Timothy Carambat --- server/utils/AiProviders/openAi/index.js | 153 ++++++++++++++++------- 1 file changed, 111 insertions(+), 42 deletions(-) diff --git a/server/utils/AiProviders/openAi/index.js b/server/utils/AiProviders/openAi/index.js index c371a1d4..bc159d76 100644 --- a/server/utils/AiProviders/openAi/index.js +++ b/server/utils/AiProviders/openAi/index.js @@ -1,7 +1,9 @@ +const { v4: uuidv4 } = require("uuid"); const { NativeEmbedder } = require("../../EmbeddingEngines/native"); const { - handleDefaultStreamResponseV2, formatChatHistory, + writeResponseChunk, + clientAbortedHandler, } = require("../../helpers/chat/responses"); const { MODEL_MAP } = require("../modelMap"); const { @@ -34,14 +36,6 @@ class OpenAiLLM { console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); } - /** - * Check if the model is an o1 model. - * @returns {boolean} - */ - get isOTypeModel() { - return this.model.startsWith("o"); - } - #appendContext(contextTexts = []) { if (!contextTexts || !contextTexts.length) return ""; return ( @@ -55,8 +49,6 @@ class OpenAiLLM { } streamingEnabled() { - // o3-mini is the only o-type model that supports streaming - if (this.isOTypeModel && this.model !== "o3-mini") return false; return "streamGetChatCompletion" in this; } @@ -96,14 +88,11 @@ class OpenAiLLM { return userPrompt; } - const content = [{ type: "text", text: userPrompt }]; + const content = [{ type: "input_text", text: userPrompt }]; for (let attachment of attachments) { content.push({ - type: "image_url", - image_url: { - url: attachment.contentString, - detail: "auto", - }, + type: "input_image", + image_url: attachment.contentString, }); } return content.flat(); @@ -121,11 +110,8 @@ class OpenAiLLM { userPrompt = "", attachments = [], // This is the specific attachment for only this prompt }) { - // o1 Models do not support the "system" role - // in order to combat this, we can use the "user" role as a replacement for now - // https://community.openai.com/t/o1-models-do-not-support-system-role-in-chat-completion/953880 const prompt = { - role: this.isOTypeModel ? "user" : "system", + role: "system", content: `${systemPrompt}${this.#appendContext(contextTexts)}`, }; return [ @@ -138,6 +124,24 @@ class OpenAiLLM { ]; } + /** + * Determine the appropriate temperature for the model. + * @param {string} modelName + * @param {number} temperature + * @returns {number} + */ + #temperature(modelName, temperature) { + // For models that don't support temperature + // OpenAI accepts temperature 1 + const NO_TEMP_MODELS = ["o", "gpt-5"]; + + if (NO_TEMP_MODELS.some((prefix) => modelName.startsWith(prefix))) { + return 1; + } + + return temperature; + } + async getChatCompletion(messages = null, { temperature = 0.7 }) { if (!(await this.isValidChatCompletionModel(this.model))) throw new Error( @@ -145,30 +149,30 @@ class OpenAiLLM { ); const result = await LLMPerformanceMonitor.measureAsyncFunction( - this.openai.chat.completions + this.openai.responses .create({ model: this.model, - messages, - temperature: this.isOTypeModel ? 1 : temperature, // o1 models only accept temperature 1 + input: messages, + store: false, + temperature: this.#temperature(this.model, temperature), }) .catch((e) => { throw new Error(e.message); }) ); - if ( - !result.output.hasOwnProperty("choices") || - result.output.choices.length === 0 - ) - return null; + if (!result.output.hasOwnProperty("output_text")) return null; + const usage = result.output.usage || {}; return { - textResponse: result.output.choices[0].message.content, + textResponse: result.output.output_text, metrics: { - prompt_tokens: result.output.usage.prompt_tokens || 0, - completion_tokens: result.output.usage.completion_tokens || 0, - total_tokens: result.output.usage.total_tokens || 0, - outputTps: result.output.usage.completion_tokens / result.duration, + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + outputTps: usage.completion_tokens + ? usage.completion_tokens / result.duration + : 0, duration: result.duration, }, }; @@ -181,23 +185,88 @@ class OpenAiLLM { ); const measuredStreamRequest = await LLMPerformanceMonitor.measureStream( - this.openai.chat.completions.create({ + this.openai.responses.create({ model: this.model, stream: true, - messages, - temperature: this.isOTypeModel ? 1 : temperature, // o1 models only accept temperature 1 + input: messages, + store: false, + temperature: this.#temperature(this.model, temperature), }), - messages - // runPromptTokenCalculation: true - We manually count the tokens because OpenAI does not provide them in the stream - // since we are not using the OpenAI API version that supports this `stream_options` param. - // TODO: implement this once we upgrade to the OpenAI API version that supports this param. + messages, + false ); return measuredStreamRequest; } handleStream(response, stream, responseProps) { - return handleDefaultStreamResponseV2(response, stream, responseProps); + const { uuid = uuidv4(), sources = [] } = responseProps; + + let hasUsageMetrics = false; + let usage = { + completion_tokens: 0, + }; + + return new Promise(async (resolve) => { + let fullText = ""; + + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + try { + for await (const chunk of stream) { + if (chunk.type === "response.output_text.delta") { + const token = chunk.delta; + if (token) { + fullText += token; + if (!hasUsageMetrics) usage.completion_tokens++; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + } else if (chunk.type === "response.completed") { + const { response: res } = chunk; + if (res.hasOwnProperty("usage") && !!res.usage) { + hasUsageMetrics = true; + usage = { ...usage, ...res.usage }; + } + + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + break; + } + } + } catch (e) { + console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`); + writeResponseChunk(response, { + uuid, + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + stream?.endMeasurement(usage); + resolve(fullText); + } + }); } // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations From 9466f671620366272228cfcc43d015aa7251a28d Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Fri, 19 Sep 2025 08:52:20 -0700 Subject: [PATCH 05/15] Update the timeout value on all stream-timeout providers: (#4412) - OpenRouter - Novita - CometAPI updated to 3,000ms default with 500ms min --- .../LLMSelection/CometApiLLMOptions/index.jsx | 2 +- .../LLMSelection/NovitaLLMOptions/index.jsx | 2 +- .../LLMSelection/OpenRouterOptions/index.jsx | 2 +- server/utils/AiProviders/cometapi/index.js | 9 +++++++-- server/utils/AiProviders/novita/index.js | 14 ++++++++++---- server/utils/AiProviders/openRouter/index.js | 19 +++++++++++++++---- 6 files changed, 35 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/LLMSelection/CometApiLLMOptions/index.jsx b/frontend/src/components/LLMSelection/CometApiLLMOptions/index.jsx index 71fbeec6..0abcf0ac 100644 --- a/frontend/src/components/LLMSelection/CometApiLLMOptions/index.jsx +++ b/frontend/src/components/LLMSelection/CometApiLLMOptions/index.jsx @@ -59,7 +59,7 @@ function AdvancedControls({ settings }) { name="CometApiLLMTimeout" className="border-none bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5" placeholder="Timeout value between token responses to auto-timeout the stream" - defaultValue={settings?.CometApiLLMTimeout ?? 500} + defaultValue={settings?.CometApiLLMTimeout ?? 3_000} autoComplete="off" onScroll={(e) => e.target.blur()} min={500} diff --git a/frontend/src/components/LLMSelection/NovitaLLMOptions/index.jsx b/frontend/src/components/LLMSelection/NovitaLLMOptions/index.jsx index 0f122b6b..b8318cc0 100644 --- a/frontend/src/components/LLMSelection/NovitaLLMOptions/index.jsx +++ b/frontend/src/components/LLMSelection/NovitaLLMOptions/index.jsx @@ -59,7 +59,7 @@ function AdvancedControls({ settings }) { name="NovitaLLMTimeout" className="border-none bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5" placeholder="Timeout value between token responses to auto-timeout the stream" - defaultValue={settings?.NovitaLLMTimeout ?? 500} + defaultValue={settings?.NovitaLLMTimeout ?? 3_000} autoComplete="off" onScroll={(e) => e.target.blur()} min={500} diff --git a/frontend/src/components/LLMSelection/OpenRouterOptions/index.jsx b/frontend/src/components/LLMSelection/OpenRouterOptions/index.jsx index dc595c84..9fdd6202 100644 --- a/frontend/src/components/LLMSelection/OpenRouterOptions/index.jsx +++ b/frontend/src/components/LLMSelection/OpenRouterOptions/index.jsx @@ -57,7 +57,7 @@ function AdvancedControls({ settings }) { name="OpenRouterTimeout" className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5" placeholder="Timeout value between token responses to auto-timeout the stream" - defaultValue={settings?.OpenRouterTimeout ?? 500} + defaultValue={settings?.OpenRouterTimeout ?? 3_000} autoComplete="off" onScroll={(e) => e.target.blur()} min={500} diff --git a/server/utils/AiProviders/cometapi/index.js b/server/utils/AiProviders/cometapi/index.js index 82fb7c1b..fca0b0cc 100644 --- a/server/utils/AiProviders/cometapi/index.js +++ b/server/utils/AiProviders/cometapi/index.js @@ -19,6 +19,7 @@ const cacheFolder = path.resolve( ); class CometApiLLM { + defaultTimeout = 3_000; constructor(embedder = null, modelPreference = null) { if (!process.env.COMETAPI_LLM_API_KEY) throw new Error("No CometAPI API key was set."); @@ -61,10 +62,14 @@ class CometApiLLM { * CometAPI has various models that never return `finish_reasons` and thus leave the stream open * which causes issues in subsequent messages. This timeout value forces us to close the stream after * x milliseconds. This is a configurable value via the COMETAPI_LLM_TIMEOUT_MS value - * @returns {number} The timeout value in milliseconds (default: 500) + * @returns {number} The timeout value in milliseconds (default: 3_000) */ #parseTimeout() { - if (isNaN(Number(process.env.COMETAPI_LLM_TIMEOUT_MS))) return 500; + this.log( + `CometAPI timeout is set to ${process.env.COMETAPI_LLM_TIMEOUT_MS ?? this.defaultTimeout}ms` + ); + if (isNaN(Number(process.env.COMETAPI_LLM_TIMEOUT_MS))) + return this.defaultTimeout; const setValue = Number(process.env.COMETAPI_LLM_TIMEOUT_MS); if (setValue < 500) return 500; return setValue; diff --git a/server/utils/AiProviders/novita/index.js b/server/utils/AiProviders/novita/index.js index 5bbf49ba..08be9cf8 100644 --- a/server/utils/AiProviders/novita/index.js +++ b/server/utils/AiProviders/novita/index.js @@ -18,6 +18,8 @@ const cacheFolder = path.resolve( ); class NovitaLLM { + defaultTimeout = 3_000; + constructor(embedder = null, modelPreference = null) { if (!process.env.NOVITA_LLM_API_KEY) throw new Error("No Novita API key was set."); @@ -62,12 +64,16 @@ class NovitaLLM { * Novita has various models that never return `finish_reasons` and thus leave the stream open * which causes issues in subsequent messages. This timeout value forces us to close the stream after * x milliseconds. This is a configurable value via the NOVITA_LLM_TIMEOUT_MS value - * @returns {number} The timeout value in milliseconds (default: 500) + * @returns {number} The timeout value in milliseconds (default: 3_000) */ #parseTimeout() { - if (isNaN(Number(process.env.NOVITA_LLM_TIMEOUT_MS))) return 500; + this.log( + `Novita timeout is set to ${process.env.NOVITA_LLM_TIMEOUT_MS ?? this.defaultTimeout}ms` + ); + if (isNaN(Number(process.env.NOVITA_LLM_TIMEOUT_MS))) + return this.defaultTimeout; const setValue = Number(process.env.NOVITA_LLM_TIMEOUT_MS); - if (setValue < 500) return 500; + if (setValue < 500) return 500; // 500ms is the minimum timeout return setValue; } @@ -318,7 +324,7 @@ class NovitaLLM { }); } - if (message.finish_reason !== null) { + if (message?.finish_reason !== null) { writeResponseChunk(response, { uuid, sources, diff --git a/server/utils/AiProviders/openRouter/index.js b/server/utils/AiProviders/openRouter/index.js index f456c151..ea1665c0 100644 --- a/server/utils/AiProviders/openRouter/index.js +++ b/server/utils/AiProviders/openRouter/index.js @@ -18,6 +18,16 @@ const cacheFolder = path.resolve( ); class OpenRouterLLM { + /** + * Some openrouter models never send a finish_reason and thus leave the stream open in the UI. + * However, because OR is a middleware it can also wait an inordinately long time between chunks so we need + * to ensure that we dont accidentally close the stream too early. If the time between chunks is greater than this timeout + * we will close the stream and assume it to be complete. This is common for free models or slow providers they can + * possibly delegate to during invocation. + * @type {number} + */ + defaultTimeout = 3_000; + constructor(embedder = null, modelPreference = null) { if (!process.env.OPENROUTER_API_KEY) throw new Error("No OpenRouter API key was set."); @@ -85,15 +95,16 @@ class OpenRouterLLM { * OpenRouter has various models that never return `finish_reasons` and thus leave the stream open * which causes issues in subsequent messages. This timeout value forces us to close the stream after * x milliseconds. This is a configurable value via the OPENROUTER_TIMEOUT_MS value - * @returns {number} The timeout value in milliseconds (default: 500) + * @returns {number} The timeout value in milliseconds (default: 3_000) */ #parseTimeout() { this.log( - `OpenRouter timeout is set to ${process.env.OPENROUTER_TIMEOUT_MS ?? 500}ms` + `OpenRouter timeout is set to ${process.env.OPENROUTER_TIMEOUT_MS ?? this.defaultTimeout}ms` ); - if (isNaN(Number(process.env.OPENROUTER_TIMEOUT_MS))) return 500; + if (isNaN(Number(process.env.OPENROUTER_TIMEOUT_MS))) + return this.defaultTimeout; const setValue = Number(process.env.OPENROUTER_TIMEOUT_MS); - if (setValue < 500) return 500; + if (setValue < 500) return 500; // 500ms is the minimum timeout return setValue; } From 3cb54fdb9c98e241ec196bcf85973df7353cf1ec Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 25 Sep 2025 18:47:18 -0500 Subject: [PATCH 06/15] [BUGFIX] Update Dell Pro AI Studio Default URL (#4433) Update DPAISOptions component and constants to use new OpenAI endpoint (#4432) - Changed placeholder in DPAISOptions component to reflect updated endpoint. - Updated DPAIS_COMMON_URLS in constants to include '/openai' in URLs. --- .../src/components/LLMSelection/DPAISOptions/index.jsx | 2 +- frontend/src/utils/constants.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/LLMSelection/DPAISOptions/index.jsx b/frontend/src/components/LLMSelection/DPAISOptions/index.jsx index e2c7187a..00995a28 100644 --- a/frontend/src/components/LLMSelection/DPAISOptions/index.jsx +++ b/frontend/src/components/LLMSelection/DPAISOptions/index.jsx @@ -92,7 +92,7 @@ export default function DellProAIStudioOptions({ type="url" name="DellProAiStudioBasePath" className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5" - placeholder="http://localhost:8553/v1" + placeholder="http://localhost:8553/v1/openai" value={basePathValue.value} required={true} autoComplete="off" diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index c6a44d2a..a6efc519 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -38,10 +38,10 @@ export const LOCALAI_COMMON_URLS = [ ]; export const DPAIS_COMMON_URLS = [ - "http://127.0.0.1:8553/v1", - "http://0.0.0.0:8553/v1", - "http://localhost:8553/v1", - "http://host.docker.internal:8553/v1", + "http://127.0.0.1:8553/v1/openai", + "http://0.0.0.0:8553/v1/openai", + "http://localhost:8553/v1/openai", + "http://host.docker.internal:8553/v1/openai", ]; export const NVIDIA_NIM_COMMON_URLS = [ From 2226f29a96040baaadbe6a01ca626845793d3923 Mon Sep 17 00:00:00 2001 From: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Date: Thu, 25 Sep 2025 18:16:03 -0700 Subject: [PATCH 07/15] Add PostgreSQL vector extension in createTableIfNotExists function (#4430) * Add PostgreSQL vector extension in createTableIfNotExists function * move extension sql to function --------- Co-authored-by: shatfield4 Co-authored-by: Timothy Carambat --- server/utils/vectorDbProviders/pgvector/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/utils/vectorDbProviders/pgvector/index.js b/server/utils/vectorDbProviders/pgvector/index.js index 057f1b79..d5c86907 100644 --- a/server/utils/vectorDbProviders/pgvector/index.js +++ b/server/utils/vectorDbProviders/pgvector/index.js @@ -44,6 +44,7 @@ const PGVector = { "SELECT * FROM pg_catalog.pg_tables WHERE schemaname = 'public'", getEmbeddingTableSchemaSql: "SELECT column_name,data_type FROM information_schema.columns WHERE table_name = $1", + createExtensionSql: "CREATE EXTENSION IF NOT EXISTS vector;", createTableSql: (dimensions) => `CREATE TABLE IF NOT EXISTS "${PGVector.tableName()}" (id UUID PRIMARY KEY, namespace TEXT, embedding vector(${Number(dimensions)}), metadata JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`, @@ -405,6 +406,7 @@ const PGVector = { */ createTableIfNotExists: async function (connection, dimensions = 384) { this.log(`Creating embedding table with ${dimensions} dimensions`); + await connection.query(this.createExtensionSql); await connection.query(this.createTableSql(dimensions)); return true; }, From 473ff9068a667e53975667dab7554b06b9ae46f6 Mon Sep 17 00:00:00 2001 From: Neha Prasad Date: Fri, 26 Sep 2025 06:57:37 +0530 Subject: [PATCH 08/15] fix: resolve Firefox search icon overlapping placeholder text (#4390) * fix: resolve Firefox search icon overlapping placeholder text - Increase input left padding from pl-4 to pl-9 to provide clearance - Remove redundant placeholder:pl-4 class - Ensures 24px spacing between search icon and text content * Update SearchBox component to adjust padding on focus state --------- Co-authored-by: neha Co-authored-by: angelplusultra Co-authored-by: Timothy Carambat --- frontend/src/components/Sidebar/SearchBox/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar/SearchBox/index.jsx b/frontend/src/components/Sidebar/SearchBox/index.jsx index d5353d3b..d03cc882 100644 --- a/frontend/src/components/Sidebar/SearchBox/index.jsx +++ b/frontend/src/components/Sidebar/SearchBox/index.jsx @@ -61,7 +61,7 @@ export default function SearchBox({ user, showNewWsModal }) { onChange={handleSearch} onReset={handleReset} onFocus={(e) => e.target.select()} - className="border-none w-full h-full rounded-lg bg-theme-sidebar-item-default pl-4 pr-1 placeholder:text-theme-settings-input-placeholder placeholder:pl-4 outline-none text-white search-input peer text-sm" + className="border-none w-full h-full rounded-lg bg-theme-sidebar-item-default pl-9 focus:pl-4 pr-1 placeholder:text-theme-settings-input-placeholder outline-none text-white search-input peer text-sm" /> Date: Thu, 25 Sep 2025 18:34:19 -0700 Subject: [PATCH 09/15] Refactor Class Name Logging (#4426) * Add className property to various LLM and embedder classes to fix logging bug after minification * Fix bug with this.log method by applying the missing private field symbol --- server/utils/AiProviders/anthropic/index.js | 3 ++- server/utils/AiProviders/apipie/index.js | 3 ++- server/utils/AiProviders/cometapi/index.js | 3 ++- server/utils/AiProviders/deepseek/index.js | 3 ++- server/utils/AiProviders/dellProAiStudio/index.js | 3 ++- server/utils/AiProviders/gemini/index.js | 3 ++- server/utils/AiProviders/genericOpenAi/index.js | 3 ++- server/utils/AiProviders/koboldCPP/index.js | 3 ++- server/utils/AiProviders/liteLLM/index.js | 3 ++- server/utils/AiProviders/mistral/index.js | 3 ++- server/utils/AiProviders/moonshotAi/index.js | 3 ++- server/utils/AiProviders/novita/index.js | 3 ++- server/utils/AiProviders/nvidiaNim/index.js | 3 ++- server/utils/AiProviders/openAi/index.js | 3 ++- server/utils/AiProviders/openRouter/index.js | 3 ++- server/utils/AiProviders/ppio/index.js | 3 ++- server/utils/AiProviders/textGenWebUI/index.js | 3 ++- server/utils/AiProviders/xai/index.js | 3 ++- server/utils/EmbeddingEngines/azureOpenAi/index.js | 3 ++- server/utils/EmbeddingEngines/gemini/index.js | 3 ++- server/utils/EmbeddingEngines/genericOpenAi/index.js | 3 ++- server/utils/EmbeddingEngines/lmstudio/index.js | 3 ++- server/utils/EmbeddingEngines/native/index.js | 3 ++- server/utils/EmbeddingEngines/ollama/index.js | 3 ++- server/utils/EmbeddingEngines/openAi/index.js | 3 ++- server/utils/MCP/hypervisor/index.js | 3 ++- .../agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js | 3 ++- .../agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js | 3 ++- .../aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js | 3 ++- server/utils/agents/aibitat/providers/gemini.js | 3 ++- 30 files changed, 60 insertions(+), 30 deletions(-) diff --git a/server/utils/AiProviders/anthropic/index.js b/server/utils/AiProviders/anthropic/index.js index 450b376b..2170ba83 100644 --- a/server/utils/AiProviders/anthropic/index.js +++ b/server/utils/AiProviders/anthropic/index.js @@ -15,6 +15,7 @@ class AnthropicLLM { if (!process.env.ANTHROPIC_API_KEY) throw new Error("No Anthropic API key was set."); + this.className = "AnthropicLLM"; // Docs: https://www.npmjs.com/package/@anthropic-ai/sdk const AnthropicAI = require("@anthropic-ai/sdk"); const anthropic = new AnthropicAI({ @@ -37,7 +38,7 @@ class AnthropicLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } streamingEnabled() { diff --git a/server/utils/AiProviders/apipie/index.js b/server/utils/AiProviders/apipie/index.js index bd794d38..534e7c27 100644 --- a/server/utils/AiProviders/apipie/index.js +++ b/server/utils/AiProviders/apipie/index.js @@ -23,6 +23,7 @@ class ApiPieLLM { if (!process.env.APIPIE_LLM_API_KEY) throw new Error("No ApiPie LLM API key was set."); + this.className = "ApiPieLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.basePath = "https://apipie.ai/v1"; this.openai = new OpenAIApi({ @@ -49,7 +50,7 @@ class ApiPieLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) diff --git a/server/utils/AiProviders/cometapi/index.js b/server/utils/AiProviders/cometapi/index.js index fca0b0cc..23e069d5 100644 --- a/server/utils/AiProviders/cometapi/index.js +++ b/server/utils/AiProviders/cometapi/index.js @@ -24,6 +24,7 @@ class CometApiLLM { if (!process.env.COMETAPI_LLM_API_KEY) throw new Error("No CometAPI API key was set."); + this.className = "CometApiLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.basePath = "https://api.cometapi.com/v1"; this.openai = new OpenAIApi({ @@ -55,7 +56,7 @@ class CometApiLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/AiProviders/deepseek/index.js b/server/utils/AiProviders/deepseek/index.js index 4b5b334b..379012d0 100644 --- a/server/utils/AiProviders/deepseek/index.js +++ b/server/utils/AiProviders/deepseek/index.js @@ -13,6 +13,7 @@ class DeepSeekLLM { constructor(embedder = null, modelPreference = null) { if (!process.env.DEEPSEEK_API_KEY) throw new Error("No DeepSeek API key was set."); + this.className = "DeepSeekLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.openai = new OpenAIApi({ @@ -35,7 +36,7 @@ class DeepSeekLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/dellProAiStudio/index.js b/server/utils/AiProviders/dellProAiStudio/index.js index 2cca4c8b..7c233ec2 100644 --- a/server/utils/AiProviders/dellProAiStudio/index.js +++ b/server/utils/AiProviders/dellProAiStudio/index.js @@ -13,6 +13,7 @@ class DellProAiStudioLLM { if (!process.env.DPAIS_LLM_BASE_PATH) throw new Error("No Dell Pro AI Studio Base Path was set."); + this.className = "DellProAiStudioLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.dpais = new OpenAIApi({ baseURL: DellProAiStudioLLM.parseBasePath(), @@ -50,7 +51,7 @@ class DellProAiStudioLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/gemini/index.js b/server/utils/AiProviders/gemini/index.js index c7d39bc7..de76ec79 100644 --- a/server/utils/AiProviders/gemini/index.js +++ b/server/utils/AiProviders/gemini/index.js @@ -29,6 +29,7 @@ class GeminiLLM { if (!process.env.GEMINI_API_KEY) throw new Error("No Gemini API key was set."); + this.className = "GeminiLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.model = modelPreference || @@ -71,7 +72,7 @@ class GeminiLLM { } #log(text, ...args) { - console.log(`\x1b[32m[GeminiLLM]\x1b[0m ${text}`, ...args); + console.log(`\x1b[32m[${this.className}]\x1b[0m ${text}`, ...args); } // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) diff --git a/server/utils/AiProviders/genericOpenAi/index.js b/server/utils/AiProviders/genericOpenAi/index.js index 3b4c179d..8e32ad59 100644 --- a/server/utils/AiProviders/genericOpenAi/index.js +++ b/server/utils/AiProviders/genericOpenAi/index.js @@ -18,6 +18,7 @@ class GenericOpenAiLLM { "GenericOpenAI must have a valid base path to use for the api." ); + this.className = "GenericOpenAiLLM"; this.basePath = process.env.GENERIC_OPEN_AI_BASE_PATH; this.openai = new OpenAIApi({ baseURL: this.basePath, @@ -45,7 +46,7 @@ class GenericOpenAiLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/koboldCPP/index.js b/server/utils/AiProviders/koboldCPP/index.js index 64fa8c45..b795b152 100644 --- a/server/utils/AiProviders/koboldCPP/index.js +++ b/server/utils/AiProviders/koboldCPP/index.js @@ -17,6 +17,7 @@ class KoboldCPPLLM { "KoboldCPP must have a valid base path to use for the api." ); + this.className = "KoboldCPPLLM"; this.basePath = process.env.KOBOLD_CPP_BASE_PATH; this.openai = new OpenAIApi({ baseURL: this.basePath, @@ -37,7 +38,7 @@ class KoboldCPPLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/liteLLM/index.js b/server/utils/AiProviders/liteLLM/index.js index 2017d777..91aa8517 100644 --- a/server/utils/AiProviders/liteLLM/index.js +++ b/server/utils/AiProviders/liteLLM/index.js @@ -15,6 +15,7 @@ class LiteLLM { "LiteLLM must have a valid base path to use for the api." ); + this.className = "LiteLLM"; this.basePath = process.env.LITE_LLM_BASE_PATH; this.openai = new OpenAIApi({ baseURL: this.basePath, @@ -35,7 +36,7 @@ class LiteLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/mistral/index.js b/server/utils/AiProviders/mistral/index.js index 6c637857..4cf547cc 100644 --- a/server/utils/AiProviders/mistral/index.js +++ b/server/utils/AiProviders/mistral/index.js @@ -12,6 +12,7 @@ class MistralLLM { if (!process.env.MISTRAL_API_KEY) throw new Error("No Mistral API key was set."); + this.className = "MistralLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.openai = new OpenAIApi({ baseURL: "https://api.mistral.ai/v1", @@ -31,7 +32,7 @@ class MistralLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/moonshotAi/index.js b/server/utils/AiProviders/moonshotAi/index.js index c4bc7b65..b00f7213 100644 --- a/server/utils/AiProviders/moonshotAi/index.js +++ b/server/utils/AiProviders/moonshotAi/index.js @@ -12,6 +12,7 @@ class MoonshotAiLLM { constructor(embedder = null, modelPreference = null) { if (!process.env.MOONSHOT_AI_API_KEY) throw new Error("No Moonshot AI API key was set."); + this.className = "MoonshotAiLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.openai = new OpenAIApi({ @@ -36,7 +37,7 @@ class MoonshotAiLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/novita/index.js b/server/utils/AiProviders/novita/index.js index 08be9cf8..69c06753 100644 --- a/server/utils/AiProviders/novita/index.js +++ b/server/utils/AiProviders/novita/index.js @@ -24,6 +24,7 @@ class NovitaLLM { if (!process.env.NOVITA_LLM_API_KEY) throw new Error("No Novita API key was set."); + this.className = "NovitaLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.basePath = "https://api.novita.ai/v3/openai"; this.openai = new OpenAIApi({ @@ -57,7 +58,7 @@ class NovitaLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/AiProviders/nvidiaNim/index.js b/server/utils/AiProviders/nvidiaNim/index.js index b421fdc1..f932625c 100644 --- a/server/utils/AiProviders/nvidiaNim/index.js +++ b/server/utils/AiProviders/nvidiaNim/index.js @@ -12,6 +12,7 @@ class NvidiaNimLLM { if (!process.env.NVIDIA_NIM_LLM_BASE_PATH) throw new Error("No NVIDIA NIM API Base Path was set."); + this.className = "NvidiaNimLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.nvidiaNim = new OpenAIApi({ baseURL: parseNvidiaNimBasePath(process.env.NVIDIA_NIM_LLM_BASE_PATH), @@ -33,7 +34,7 @@ class NvidiaNimLLM { } #log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/openAi/index.js b/server/utils/AiProviders/openAi/index.js index bc159d76..001c27ea 100644 --- a/server/utils/AiProviders/openAi/index.js +++ b/server/utils/AiProviders/openAi/index.js @@ -13,6 +13,7 @@ const { class OpenAiLLM { constructor(embedder = null, modelPreference = null) { if (!process.env.OPEN_AI_KEY) throw new Error("No OpenAI API key was set."); + this.className = "OpenAiLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.openai = new OpenAIApi({ @@ -33,7 +34,7 @@ class OpenAiLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/openRouter/index.js b/server/utils/AiProviders/openRouter/index.js index ea1665c0..7a0fc1c3 100644 --- a/server/utils/AiProviders/openRouter/index.js +++ b/server/utils/AiProviders/openRouter/index.js @@ -32,6 +32,7 @@ class OpenRouterLLM { if (!process.env.OPENROUTER_API_KEY) throw new Error("No OpenRouter API key was set."); + this.className = "OpenRouterLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.basePath = "https://openrouter.ai/api/v1"; this.openai = new OpenAIApi({ @@ -88,7 +89,7 @@ class OpenRouterLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/AiProviders/ppio/index.js b/server/utils/AiProviders/ppio/index.js index 677cd4cd..bef7fada 100644 --- a/server/utils/AiProviders/ppio/index.js +++ b/server/utils/AiProviders/ppio/index.js @@ -18,6 +18,7 @@ class PPIOLLM { constructor(embedder = null, modelPreference = null) { if (!process.env.PPIO_API_KEY) throw new Error("No PPIO API key was set."); + this.className = "PPIOLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.basePath = "https://api.ppinfra.com/v3/openai/"; this.openai = new OpenAIApi({ @@ -50,7 +51,7 @@ class PPIOLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } async #syncModels() { diff --git a/server/utils/AiProviders/textGenWebUI/index.js b/server/utils/AiProviders/textGenWebUI/index.js index f3647c06..2de1d8cd 100644 --- a/server/utils/AiProviders/textGenWebUI/index.js +++ b/server/utils/AiProviders/textGenWebUI/index.js @@ -15,6 +15,7 @@ class TextGenWebUILLM { "TextGenWebUI must have a valid base path to use for the api." ); + this.className = "TextGenWebUILLM"; this.basePath = process.env.TEXT_GEN_WEB_UI_BASE_PATH; this.openai = new OpenAIApi({ baseURL: this.basePath, @@ -33,7 +34,7 @@ class TextGenWebUILLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/AiProviders/xai/index.js b/server/utils/AiProviders/xai/index.js index f9116270..df925715 100644 --- a/server/utils/AiProviders/xai/index.js +++ b/server/utils/AiProviders/xai/index.js @@ -12,6 +12,7 @@ class XAiLLM { constructor(embedder = null, modelPreference = null) { if (!process.env.XAI_LLM_API_KEY) throw new Error("No xAI API key was set."); + this.className = "XAiLLM"; const { OpenAI: OpenAIApi } = require("openai"); this.openai = new OpenAIApi({ @@ -34,7 +35,7 @@ class XAiLLM { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } #appendContext(contextTexts = []) { diff --git a/server/utils/EmbeddingEngines/azureOpenAi/index.js b/server/utils/EmbeddingEngines/azureOpenAi/index.js index 57907f45..79fd000d 100644 --- a/server/utils/EmbeddingEngines/azureOpenAi/index.js +++ b/server/utils/EmbeddingEngines/azureOpenAi/index.js @@ -8,6 +8,7 @@ class AzureOpenAiEmbedder { if (!process.env.AZURE_OPENAI_KEY) throw new Error("No Azure API key was set."); + this.className = "AzureOpenAiEmbedder"; this.apiVersion = "2024-12-01-preview"; const openai = new AzureOpenAI({ apiKey: process.env.AZURE_OPENAI_KEY, @@ -29,7 +30,7 @@ class AzureOpenAiEmbedder { } log(text, ...args) { - console.log(`\x1b[36m[AzureOpenAiEmbedder]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } async embedTextInput(textInput) { diff --git a/server/utils/EmbeddingEngines/gemini/index.js b/server/utils/EmbeddingEngines/gemini/index.js index 8c505466..59eb22cb 100644 --- a/server/utils/EmbeddingEngines/gemini/index.js +++ b/server/utils/EmbeddingEngines/gemini/index.js @@ -11,6 +11,7 @@ class GeminiEmbedder { if (!process.env.GEMINI_EMBEDDING_API_KEY) throw new Error("No Gemini API key was set."); + this.className = "GeminiEmbedder"; const { OpenAI: OpenAIApi } = require("openai"); this.model = process.env.EMBEDDING_MODEL_PREF || "text-embedding-004"; this.openai = new OpenAIApi({ @@ -29,7 +30,7 @@ class GeminiEmbedder { } log(text, ...args) { - console.log(`\x1b[36m[GeminiEmbedder]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/EmbeddingEngines/genericOpenAi/index.js b/server/utils/EmbeddingEngines/genericOpenAi/index.js index a8a3ac1a..f8885f81 100644 --- a/server/utils/EmbeddingEngines/genericOpenAi/index.js +++ b/server/utils/EmbeddingEngines/genericOpenAi/index.js @@ -6,6 +6,7 @@ class GenericOpenAiEmbedder { throw new Error( "GenericOpenAI must have a valid base path to use for the api." ); + this.className = "GenericOpenAiEmbedder"; const { OpenAI: OpenAIApi } = require("openai"); this.basePath = process.env.EMBEDDING_BASE_PATH; this.openai = new OpenAIApi({ @@ -25,7 +26,7 @@ class GenericOpenAiEmbedder { } log(text, ...args) { - console.log(`\x1b[36m[GenericOpenAiEmbedder]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/EmbeddingEngines/lmstudio/index.js b/server/utils/EmbeddingEngines/lmstudio/index.js index e94f45aa..0e9a5e0e 100644 --- a/server/utils/EmbeddingEngines/lmstudio/index.js +++ b/server/utils/EmbeddingEngines/lmstudio/index.js @@ -8,6 +8,7 @@ class LMStudioEmbedder { if (!process.env.EMBEDDING_MODEL_PREF) throw new Error("No embedding model was set."); + this.className = "LMStudioEmbedder"; const { OpenAI: OpenAIApi } = require("openai"); this.lmstudio = new OpenAIApi({ baseURL: parseLMStudioBasePath(process.env.EMBEDDING_BASE_PATH), @@ -21,7 +22,7 @@ class LMStudioEmbedder { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } async #isAlive() { diff --git a/server/utils/EmbeddingEngines/native/index.js b/server/utils/EmbeddingEngines/native/index.js index 9142d3b3..21773fcb 100644 --- a/server/utils/EmbeddingEngines/native/index.js +++ b/server/utils/EmbeddingEngines/native/index.js @@ -30,6 +30,7 @@ class NativeEmbedder { #fallbackHost = "https://cdn.anythingllm.com/support/models/"; constructor() { + this.className = "NativeEmbedder"; this.model = this.getEmbeddingModel(); this.modelInfo = this.getEmbedderInfo(); this.cacheDir = path.resolve( @@ -50,7 +51,7 @@ class NativeEmbedder { } log(text, ...args) { - console.log(`\x1b[36m[NativeEmbedder]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/EmbeddingEngines/ollama/index.js b/server/utils/EmbeddingEngines/ollama/index.js index 7e2f636e..2f7241e6 100644 --- a/server/utils/EmbeddingEngines/ollama/index.js +++ b/server/utils/EmbeddingEngines/ollama/index.js @@ -8,6 +8,7 @@ class OllamaEmbedder { if (!process.env.EMBEDDING_MODEL_PREF) throw new Error("No embedding model was set."); + this.className = "OllamaEmbedder"; this.basePath = process.env.EMBEDDING_BASE_PATH; this.model = process.env.EMBEDDING_MODEL_PREF; // Limit of how many strings we can process in a single pass to stay with resource or network limits @@ -20,7 +21,7 @@ class OllamaEmbedder { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/EmbeddingEngines/openAi/index.js b/server/utils/EmbeddingEngines/openAi/index.js index 9976ef54..dd517ae3 100644 --- a/server/utils/EmbeddingEngines/openAi/index.js +++ b/server/utils/EmbeddingEngines/openAi/index.js @@ -3,6 +3,7 @@ const { toChunks } = require("../../helpers"); class OpenAiEmbedder { constructor() { if (!process.env.OPEN_AI_KEY) throw new Error("No OpenAI API key was set."); + this.className = "OpenAiEmbedder"; const { OpenAI: OpenAIApi } = require("openai"); this.openai = new OpenAIApi({ apiKey: process.env.OPEN_AI_KEY, @@ -17,7 +18,7 @@ class OpenAiEmbedder { } log(text, ...args) { - console.log(`\x1b[36m[OpenAiEmbedder]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } async embedTextInput(textInput) { diff --git a/server/utils/MCP/hypervisor/index.js b/server/utils/MCP/hypervisor/index.js index 0cd1f6ce..861274f2 100644 --- a/server/utils/MCP/hypervisor/index.js +++ b/server/utils/MCP/hypervisor/index.js @@ -53,6 +53,7 @@ class MCPHypervisor { constructor() { if (MCPHypervisor._instance) return MCPHypervisor._instance; MCPHypervisor._instance = this; + this.className = "MCPHypervisor"; this.log("Initializing MCP Hypervisor - subsequent calls will boot faster"); this.#setupConfigFile(); return this; @@ -88,7 +89,7 @@ class MCPHypervisor { } log(text, ...args) { - console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); } /** diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js index d11314f6..584e2f63 100644 --- a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js @@ -28,6 +28,7 @@ class MSSQLConnector { connectionString: null, // we will force into RFC-3986 } ) { + this.className = "MSSQLConnector"; this.connectionString = config.connectionString; this._client = null; this.#parseDatabase(); @@ -72,7 +73,7 @@ class MSSQLConnector { result.rows = query.recordset; result.count = query.rowsAffected.reduce((sum, a) => sum + a, 0); } catch (err) { - console.log(this.constructor.name, err); + console.log(this.className, err); result.error = err.message; } finally { // Check client is connected before closing since we use this for validation diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js index 7a566cb7..7fa4c6a5 100644 --- a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js @@ -9,6 +9,7 @@ class MySQLConnector { connectionString: null, } ) { + this.className = "MySQLConnector"; this.connectionString = config.connectionString; this._client = null; this.database_id = this.#parseDatabase(); @@ -39,7 +40,7 @@ class MySQLConnector { result.rows = query; result.count = query?.length; } catch (err) { - console.log(this.constructor.name, err); + console.log(this.className, err); result.error = err.message; } finally { // Check client is connected before closing since we use this for validation diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js index 15ca6dde..d77c1bf5 100644 --- a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js @@ -8,6 +8,7 @@ class PostgresSQLConnector { schema: null, } ) { + this.className = "PostgresSQLConnector"; this.connectionString = config.connectionString; this.schema = config.schema || "public"; this._client = new pgSql.Client({ @@ -34,7 +35,7 @@ class PostgresSQLConnector { result.rows = query.rows; result.count = query.rowCount; } catch (err) { - console.log(this.constructor.name, err); + console.log(this.className, err); result.error = err.message; } finally { // Check client is connected before closing since we use this for validation diff --git a/server/utils/agents/aibitat/providers/gemini.js b/server/utils/agents/aibitat/providers/gemini.js index c62357bb..d624bd64 100644 --- a/server/utils/agents/aibitat/providers/gemini.js +++ b/server/utils/agents/aibitat/providers/gemini.js @@ -17,6 +17,7 @@ class GeminiProvider extends InheritMultiple([Provider, UnTooled]) { constructor(config = {}) { const { model = "gemini-2.0-flash-lite" } = config; super(); + this.className = "GeminiProvider"; const client = new OpenAI({ baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", apiKey: process.env.GEMINI_API_KEY, @@ -134,7 +135,7 @@ class GeminiProvider extends InheritMultiple([Provider, UnTooled]) { } catch (error) { throw new APIError( error?.message - ? `${this.constructor.name} encountered an error while executing the request: ${error.message}` + ? `${this.className} encountered an error while executing the request: ${error.message}` : "There was an error with the Gemini provider executing the request" ); } From ac444c8fa566fe1542b1edef60d86ea1833425ea Mon Sep 17 00:00:00 2001 From: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:01:01 -0700 Subject: [PATCH 10/15] Change incorrect notation of Weaviate to PG Vector in env.example (#4439) Change incorrect notation of Weaviate to PG Vector --- server/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/.env.example b/server/.env.example index c60319ab..6e63abb1 100644 --- a/server/.env.example +++ b/server/.env.example @@ -228,7 +228,7 @@ SIG_SALT='salt' # Please generate random string at least 32 chars long. # Enable all below if you are using vector database: LanceDB. VECTOR_DB="lancedb" -# Enable all below if you are using vector database: Weaviate. +# Enable all below if you are using vector database: PG Vector. # VECTOR_DB="pgvector" # PGVECTOR_CONNECTION_STRING="postgresql://dbuser:dbuserpass@localhost:5432/yourdb" # PGVECTOR_TABLE_NAME="anythingllm_vectors" # optional, but can be defined From c8f13d5f279e8f9e1f8ec59e5915c9bd1efbd526 Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Mon, 29 Sep 2025 09:32:55 -1000 Subject: [PATCH 11/15] Enable custom HTTP response timeout for ollama (#4448) --- docker/.env.example | 1 + server/.env.example | 1 + server/utils/AiProviders/ollama/index.js | 43 +++++++++++++++++++++++- server/utils/helpers/updateENV.js | 3 ++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docker/.env.example b/docker/.env.example index 421d0536..635c1159 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -44,6 +44,7 @@ GID='1000' # OLLAMA_MODEL_PREF='llama2' # OLLAMA_MODEL_TOKEN_LIMIT=4096 # OLLAMA_AUTH_TOKEN='your-ollama-auth-token-here (optional, only for ollama running behind auth - Bearer token)' +# OLLAMA_RESPONSE_TIMEOUT=7200000 (optional, max timeout in milliseconds for ollama response to conclude. Default is 5min before aborting) # LLM_PROVIDER='togetherai' # TOGETHER_AI_API_KEY='my-together-ai-key' diff --git a/server/.env.example b/server/.env.example index 6e63abb1..1429b1f8 100644 --- a/server/.env.example +++ b/server/.env.example @@ -41,6 +41,7 @@ SIG_SALT='salt' # Please generate random string at least 32 chars long. # OLLAMA_MODEL_PREF='llama2' # OLLAMA_MODEL_TOKEN_LIMIT=4096 # OLLAMA_AUTH_TOKEN='your-ollama-auth-token-here (optional, only for ollama running behind auth - Bearer token)' +# OLLAMA_RESPONSE_TIMEOUT=7200000 (optional, max timeout in milliseconds for ollama response to conclude. Default is 5min before aborting) # LLM_PROVIDER='togetherai' # TOGETHER_AI_API_KEY='my-together-ai-key' diff --git a/server/utils/AiProviders/ollama/index.js b/server/utils/AiProviders/ollama/index.js index d7c8b15e..470a91fd 100644 --- a/server/utils/AiProviders/ollama/index.js +++ b/server/utils/AiProviders/ollama/index.js @@ -31,7 +31,11 @@ class OllamaAILLM { const headers = this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}; - this.client = new Ollama({ host: this.basePath, headers: headers }); + this.client = new Ollama({ + host: this.basePath, + headers: headers, + fetch: this.#applyFetch(), + }); this.embedder = embedder ?? new NativeEmbedder(); this.defaultTemp = 0.7; this.#log( @@ -55,6 +59,43 @@ class OllamaAILLM { ); } + /** + * Apply a custom fetch function to the Ollama client. + * This is useful when we want to bypass the default 5m timeout for global fetch + * for machines which run responses very slowly. + * @returns {Function} The custom fetch function. + */ + #applyFetch() { + try { + if (!("OLLAMA_RESPONSE_TIMEOUT" in process.env)) return fetch; + const { Agent } = require("undici"); + const moment = require("moment"); + let timeout = process.env.OLLAMA_RESPONSE_TIMEOUT; + + if (!timeout || isNaN(Number(timeout)) || Number(timeout) <= 5 * 60_000) { + this.#log( + "Timeout option was not set, is not a number, or is less than 5 minutes in ms - falling back to default", + { timeout } + ); + return fetch; + } else timeout = Number(timeout); + + const noTimeoutFetch = (input, init = {}) => { + return fetch(input, { + ...init, + dispatcher: new Agent({ headersTimeout: timeout }), + }); + }; + + const humanDiff = moment.duration(timeout).humanize(); + this.#log(`Applying custom fetch w/timeout of ${humanDiff}.`); + return noTimeoutFetch; + } catch (error) { + this.#log("Error applying custom fetch - using default fetch", error); + return fetch; + } + } + streamingEnabled() { return "streamGetChatCompletion" in this; } diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js index 90322378..04484d09 100644 --- a/server/utils/helpers/updateENV.js +++ b/server/utils/helpers/updateENV.js @@ -1170,6 +1170,9 @@ function dumpENV() { // Specify Chromium args for collector "ANYTHINGLLM_CHROMIUM_ARGS", + + // Allow setting a custom response timeout for Ollama + "OLLAMA_RESPONSE_TIMEOUT", ]; // Simple sanitization of each value to prevent ENV injection via newline or quote escaping. From 8fc1f24d1b66c78617b31f20ecd926b31e42b736 Mon Sep 17 00:00:00 2001 From: AoiYamada <19519928+AoiYamada@users.noreply.github.com> Date: Tue, 30 Sep 2025 04:22:50 +0800 Subject: [PATCH 12/15] fix: youtube transcript collector not work well with non en or non asr caption (#4442) * fix: youtube transcript collector not work well with non en or non asr caption * stub YT test in Github actions --------- Co-authored-by: Timothy Carambat --- .../YoutubeLoader/youtube-transcript.test.js | 38 +++++--- .../YoutubeLoader/youtube-transcript.js | 91 ++++++++++++++++++- 2 files changed, 115 insertions(+), 14 deletions(-) diff --git a/collector/__tests__/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.test.js b/collector/__tests__/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.test.js index 1fca742f..31deba38 100644 --- a/collector/__tests__/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.test.js +++ b/collector/__tests__/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.test.js @@ -1,16 +1,32 @@ const { YoutubeTranscript } = require("../../../../../utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js"); describe("YoutubeTranscript", () => { - it("should fetch transcript from YouTube video", async () => { - const videoId = "BJjsfNO5JTo"; - const transcript = await YoutubeTranscript.fetchTranscript(videoId, { - lang: "en", - }); + if (process.env.GITHUB_ACTIONS) { + console.log("Skipping YoutubeTranscript test in GitHub Actions as the URLs will not resolve."); + it('is stubbed in GitHub Actions', () => expect(true).toBe(true)); + } else { + it("should fetch transcript from YouTube video", async () => { + const videoId = "BJjsfNO5JTo"; + const transcript = await YoutubeTranscript.fetchTranscript(videoId, { + lang: "en", + }); - expect(transcript).toBeDefined(); - expect(typeof transcript).toBe("string"); - expect(transcript.length).toBeGreaterThan(0); - // console.log("Success! Transcript length:", transcript.length); - // console.log("First 200 characters:", transcript.substring(0, 200) + "..."); - }, 30000); + expect(transcript).toBeDefined(); + expect(typeof transcript).toBe("string"); + expect(transcript.length).toBeGreaterThan(0); + console.log("First 200 characters:", transcript.substring(0, 200) + "..."); + }, 30000); + + it("should fetch non asr transcript from YouTube video", async () => { + const videoId = "D111ao6wWH0"; + const transcript = await YoutubeTranscript.fetchTranscript(videoId, { + lang: "zh-HK", + }); + + expect(transcript).toBeDefined(); + expect(typeof transcript).toBe("string"); + expect(transcript.length).toBeGreaterThan(0); + console.log("First 200 characters:", transcript.substring(0, 200) + "..."); + }, 30000); + } }); diff --git a/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js b/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js index 3f0a4c43..4807a0ac 100644 --- a/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js +++ b/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js @@ -85,6 +85,85 @@ class YoutubeTranscript { .replace(/\s+/g, " "); } + /** + * Calculates a preference score for a caption track to determine the best match + * @param {Object} track - The caption track object from YouTube + * @param {string} track.languageCode - ISO language code (e.g., 'zh-HK', 'en', 'es') + * @param {string} track.kind - Track type ('asr' for auto-generated, "" for human-transcribed) + * @param {string[]} preferredLanguages - Array of language codes in preference order (e.g., ['zh-HK', 'en']) + * @returns {number} Preference score (lower is better) + */ + static #calculatePreferenceScore(track, preferredLanguages) { + // Language preference: index in preferredLanguages array (0 = most preferred) + const languagePreference = preferredLanguages.indexOf(track.languageCode); + const languageScore = languagePreference === -1 ? 9999 : languagePreference; + + // Kind bonus: prefer human-transcribed (undefined) over auto-generated ('asr') + const kindBonus = track.kind === "asr" ? 0.5 : 0; + + return languageScore + kindBonus; + } + + /** + * Finds the most suitable caption track based on preferred languages + * @param {string} videoBody - The raw HTML response from YouTube + * @param {string[]} preferredLanguages - Array of language codes in preference order + * @returns {Object|null} The selected caption track or null if none found + */ + static #findPreferredCaptionTrack(videoBody, preferredLanguages) { + const captionsConfigJson = videoBody.match( + /"captions":(.*?),"videoDetails":/s + ); + + const captionsConfig = captionsConfigJson?.[1] + ? JSON.parse(captionsConfigJson[1]) + : null; + + const captionTracks = captionsConfig + ? captionsConfig.playerCaptionsTracklistRenderer.captionTracks + : null; + + if (!captionTracks || captionTracks.length === 0) { + return null; + } + + const sortedTracks = [...captionTracks].sort((a, b) => { + const scoreA = this.#calculatePreferenceScore(a, preferredLanguages); + const scoreB = this.#calculatePreferenceScore(b, preferredLanguages); + return scoreA - scoreB; + }); + + return sortedTracks[0]; + } + + /** + * Fetches video page content and finds the preferred caption track + * @param {string} videoId - YouTube video ID + * @param {string[]} preferredLanguages - Array of preferred language codes + * @returns {Promise} The preferred caption track + * @throws {YoutubeTranscriptError} If no suitable caption track is found + */ + static async #getPreferredCaptionTrack(videoId, preferredLanguages) { + const videoResponse = await fetch( + `https://www.youtube.com/watch?v=${videoId}`, + { credentials: "omit" } + ); + const videoBody = await videoResponse.text(); + + const preferredCaptionTrack = this.#findPreferredCaptionTrack( + videoBody, + preferredLanguages + ); + + if (!preferredCaptionTrack) { + throw new YoutubeTranscriptError( + "No suitable caption track found for the video" + ); + } + + return preferredCaptionTrack; + } + /** * Fetch transcript from YouTube video * @param {string} videoId - Video URL or video identifier @@ -93,14 +172,20 @@ class YoutubeTranscript { * @returns {Promise} Video transcript text */ static async fetchTranscript(videoId, config = {}) { + const preferredLanguages = config?.lang ? [config?.lang, "en"] : ["en"]; const identifier = this.retrieveVideoId(videoId); - const lang = config?.lang ?? "en"; try { + const preferredCaptionTrack = await this.#getPreferredCaptionTrack( + identifier, + preferredLanguages + ); + const innerProto = this.#getBase64Protobuf({ - param1: "asr", - param2: lang, + param1: preferredCaptionTrack.kind || "", + param2: preferredCaptionTrack.languageCode, }); + const params = this.#getBase64Protobuf({ param1: identifier, param2: innerProto, From eb778761273340c77a4af7b75aa9b7d6dfca80f5 Mon Sep 17 00:00:00 2001 From: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:33:15 -0700 Subject: [PATCH 13/15] Add HTTP request/response logging middleware for development mode (#4425) * Add HTTP request logging middleware for development mode - Introduced httpLogger middleware to log HTTP requests and responses. - Enabled logging only in development mode to assist with debugging. * Update httpLogger middleware to disable time logging by default * Add httpLogger middleware for development mode in collector service * Refactor httpLogger middleware to rename timeLogs parameter to enableTimestamps for clarity * Make HTTP Logger only mount in development and environment flag is enabled. * Update .env.example to clarify HTTP Logger configuration comments --------- Co-authored-by: Timothy Carambat --- collector/.env.example | 5 +++++ collector/.gitignore | 3 +++ collector/index.js | 12 ++++++++++++ collector/middleware/httpLogger.js | 29 +++++++++++++++++++++++++++++ server/.env.example | 7 ++++++- server/index.js | 12 ++++++++++++ server/middleware/httpLogger.js | 23 +++++++++++++++++++++++ 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 collector/middleware/httpLogger.js create mode 100644 server/middleware/httpLogger.js diff --git a/collector/.env.example b/collector/.env.example index ea4aba39..63daf46c 100644 --- a/collector/.env.example +++ b/collector/.env.example @@ -1 +1,6 @@ # Placeholder .env file for collector runtime + +# This enables HTTP request/response logging in development. Set value to truthy string to enable, leave empty value or comment out to disable +# ENABLE_HTTP_LOGGER="" +# This enables timestamps for the HTTP Logger. Set value to true to enable, leave empty or comment out to disable +# ENABLE_HTTP_LOGGER_TIMESTAMPS="" \ No newline at end of file diff --git a/collector/.gitignore b/collector/.gitignore index 57436cef..86ced2aa 100644 --- a/collector/.gitignore +++ b/collector/.gitignore @@ -4,3 +4,6 @@ yarn-error.log !yarn.lock outputs scripts +.env.development +.env.production +.env.test diff --git a/collector/index.js b/collector/index.js index a0dd0e56..7955ce99 100644 --- a/collector/index.js +++ b/collector/index.js @@ -15,9 +15,21 @@ const { wipeCollectorStorage } = require("./utils/files"); const extensions = require("./extensions"); const { processRawText } = require("./processRawText"); const { verifyPayloadIntegrity } = require("./middleware/verifyIntegrity"); +const { httpLogger } = require("./middleware/httpLogger"); const app = express(); const FILE_LIMIT = "3GB"; +// Only log HTTP requests in development mode and if the ENABLE_HTTP_LOGGER environment variable is set to true +if ( + process.env.NODE_ENV === "development" && + !!process.env.ENABLE_HTTP_LOGGER +) { + app.use( + httpLogger({ + enableTimestamps: !!process.env.ENABLE_HTTP_LOGGER_TIMESTAMPS, + }) + ); +} app.use(cors({ origin: true })); app.use( bodyParser.text({ limit: FILE_LIMIT }), diff --git a/collector/middleware/httpLogger.js b/collector/middleware/httpLogger.js new file mode 100644 index 00000000..6fa20be0 --- /dev/null +++ b/collector/middleware/httpLogger.js @@ -0,0 +1,29 @@ +const httpLogger = + ({ enableTimestamps = false }) => + (req, res, next) => { + // Capture the original res.end to log response status + const originalEnd = res.end; + + res.end = function (chunk, encoding) { + // Log the request method, status code, and path + const statusColor = res.statusCode >= 400 ? "\x1b[31m" : "\x1b[32m"; // Red for errors, green for success + console.log( + `\x1b[32m[HTTP]\x1b[0m ${statusColor}${res.statusCode}\x1b[0m ${ + req.method + } -> ${req.path} ${ + enableTimestamps + ? `@ ${new Date().toLocaleTimeString("en-US", { hour12: true })}` + : "" + }`.trim() + ); + + // Call the original end method + return originalEnd.call(this, chunk, encoding); + }; + + next(); + }; + +module.exports = { + httpLogger, +}; diff --git a/server/.env.example b/server/.env.example index 1429b1f8..210ddd79 100644 --- a/server/.env.example +++ b/server/.env.example @@ -368,4 +368,9 @@ TTS_PROVIDER="native" # Runtime flags for built-in pupeeteer Chromium instance # This is only required on Linux machines running AnythingLLM via Docker # and do not want to use the --cap-add=SYS_ADMIN docker argument -# ANYTHINGLLM_CHROMIUM_ARGS="--no-sandbox,--disable-setuid-sandbox" \ No newline at end of file +# ANYTHINGLLM_CHROMIUM_ARGS="--no-sandbox,--disable-setuid-sandbox" + +# This enables HTTP request/response logging in development. Set value to a truthy string to enable, leave empty value or comment out to disable. +# ENABLE_HTTP_LOGGER="" +# This enables timestamps for the HTTP Logger. Set value to a truthy string to enable, leave empty value or comment out to disable. +# ENABLE_HTTP_LOGGER_TIMESTAMPS="" \ No newline at end of file diff --git a/server/index.js b/server/index.js index 1779035d..e578c3f6 100644 --- a/server/index.js +++ b/server/index.js @@ -29,10 +29,22 @@ const { communityHubEndpoints } = require("./endpoints/communityHub"); const { agentFlowEndpoints } = require("./endpoints/agentFlows"); const { mcpServersEndpoints } = require("./endpoints/mcpServers"); const { mobileEndpoints } = require("./endpoints/mobile"); +const { httpLogger } = require("./middleware/httpLogger"); const app = express(); const apiRouter = express.Router(); const FILE_LIMIT = "3GB"; +// Only log HTTP requests in development mode and if the ENABLE_HTTP_LOGGER environment variable is set to true +if ( + process.env.NODE_ENV === "development" && + !!process.env.ENABLE_HTTP_LOGGER +) { + app.use( + httpLogger({ + enableTimestamps: !!process.env.ENABLE_HTTP_LOGGER_TIMESTAMPS, + }) + ); +} app.use(cors({ origin: true })); app.use(bodyParser.text({ limit: FILE_LIMIT })); app.use(bodyParser.json({ limit: FILE_LIMIT })); diff --git a/server/middleware/httpLogger.js b/server/middleware/httpLogger.js new file mode 100644 index 00000000..627505ff --- /dev/null +++ b/server/middleware/httpLogger.js @@ -0,0 +1,23 @@ +const httpLogger = + ({ enableTimestamps = false }) => + (req, res, next) => { + // Capture the original res.end to log response status + const originalEnd = res.end; + + res.end = function (chunk, encoding) { + // Log the request method, status code, and path + const statusColor = res.statusCode >= 400 ? "\x1b[31m" : "\x1b[32m"; // Red for errors, green for success + console.log( + `\x1b[32m[HTTP]\x1b[0m ${statusColor}${res.statusCode}\x1b[0m ${req.method} -> ${req.path} ${enableTimestamps ? `@ ${new Date().toLocaleTimeString("en-US", { hour12: true })}` : ""}`.trim() + ); + + // Call the original end method + return originalEnd.call(this, chunk, encoding); + }; + + next(); + }; + +module.exports = { + httpLogger, +}; From 7ca2753c24b97ce10a3467aee6e0a3a1b24b7ae2 Mon Sep 17 00:00:00 2001 From: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:49:45 -0700 Subject: [PATCH 14/15] Sanitize Metadata Before PG Vector Database Insertion (#4434) * Fix JSDOC for updateOrCreateCollection * Add sanitizeForJsonb method to PGVector for safe JSONB handling This new method recursively sanitizes values intended for JSONB storage, removing disallowed control characters and ensuring safe insertion into PostgreSQL. The method is integrated into the vector insertion process to sanitize metadata before database operations. * Add unit tests for PGVector.sanitizeForJsonb method This commit introduces a comprehensive test suite for the PGVector.sanitizeForJsonb method, ensuring it correctly handles various input types, including null, undefined, strings with disallowed control characters, objects, arrays, and Date objects. The tests verify that the method sanitizes inputs without mutating the original data structures. --------- Co-authored-by: Timothy Carambat --- .../vectorDbProviders/pgvector/index.test.js | 76 +++++++++++++++++++ .../utils/vectorDbProviders/pgvector/index.js | 60 ++++++++++++++- 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 server/__tests__/utils/vectorDbProviders/pgvector/index.test.js diff --git a/server/__tests__/utils/vectorDbProviders/pgvector/index.test.js b/server/__tests__/utils/vectorDbProviders/pgvector/index.test.js new file mode 100644 index 00000000..33d6266a --- /dev/null +++ b/server/__tests__/utils/vectorDbProviders/pgvector/index.test.js @@ -0,0 +1,76 @@ +const { PGVector } = require("../../../../utils/vectorDbProviders/pgvector"); + +describe("PGVector.sanitizeForJsonb", () => { + it("returns null/undefined as-is", () => { + expect(PGVector.sanitizeForJsonb(null)).toBeNull(); + expect(PGVector.sanitizeForJsonb(undefined)).toBeUndefined(); + }); + + it("keeps safe whitespace (tab, LF, CR) and removes disallowed C0 controls", () => { + const input = "a\u0000\u0001\u0002\tline\ncarriage\rreturn\u001Fend"; + const result = PGVector.sanitizeForJsonb(input); + // Expect all < 0x20 except 9,10,13 removed; keep letters and allowed whitespace + expect(result).toBe("a\tline\ncarriage\rreturnend"); + }); + + it("removes only disallowed control chars; keeps normal printable chars", () => { + const input = "Hello\u0000, World! \u0007\u0008\u000B\u000C\u001F"; + const result = PGVector.sanitizeForJsonb(input); + expect(result).toBe("Hello, World! "); + }); + + it("deeply sanitizes objects", () => { + const input = { + plain: "ok", + bad: "has\u0000nul", + nested: { + arr: ["fine", "bad\u0001", { deep: "\u0002oops" }], + }, + }; + const result = PGVector.sanitizeForJsonb(input); + expect(result).toEqual({ + plain: "ok", + bad: "hasnul", + nested: { arr: ["fine", "bad", { deep: "oops" }] }, + }); + }); + + it("deeply sanitizes arrays", () => { + const input = ["\u0000", 1, true, { s: "bad\u0003" }, ["ok", "\u0004bad"]]; + const result = PGVector.sanitizeForJsonb(input); + expect(result).toEqual(["", 1, true, { s: "bad" }, ["ok", "bad"]]); + }); + + it("converts Date to ISO string", () => { + const d = new Date("2020-01-02T03:04:05.000Z"); + expect(PGVector.sanitizeForJsonb(d)).toBe(d.toISOString()); + }); + + it("returns primitives unchanged (number, boolean, bigint)", () => { + expect(PGVector.sanitizeForJsonb(42)).toBe(42); + expect(PGVector.sanitizeForJsonb(3.14)).toBe(3.14); + expect(PGVector.sanitizeForJsonb(true)).toBe(true); + expect(PGVector.sanitizeForJsonb(false)).toBe(false); + expect(PGVector.sanitizeForJsonb(BigInt(1))).toBe(BigInt(1)); + }); + + it("returns symbol unchanged", () => { + const sym = Symbol("x"); + expect(PGVector.sanitizeForJsonb(sym)).toBe(sym); + }); + + it("does not mutate original objects/arrays", () => { + const obj = { a: "bad\u0000", nested: { b: "ok" } }; + const arr = ["\u0001", { c: "bad\u0002" }]; + const objCopy = JSON.parse(JSON.stringify(obj)); + const arrCopy = JSON.parse(JSON.stringify(arr)); + const resultObj = PGVector.sanitizeForJsonb(obj); + const resultArr = PGVector.sanitizeForJsonb(arr); + // Original inputs remain unchanged + expect(obj).toEqual(objCopy); + expect(arr).toEqual(arrCopy); + // Results are sanitized copies + expect(resultObj).toEqual({ a: "bad", nested: { b: "ok" } }); + expect(resultArr).toEqual(["", { c: "bad" }]); + }); +}); diff --git a/server/utils/vectorDbProviders/pgvector/index.js b/server/utils/vectorDbProviders/pgvector/index.js index d5c86907..990498eb 100644 --- a/server/utils/vectorDbProviders/pgvector/index.js +++ b/server/utils/vectorDbProviders/pgvector/index.js @@ -52,6 +52,55 @@ const PGVector = { console.log(`\x1b[35m[PGVectorDb]\x1b[0m ${message}`, ...args); }, + /** + * Recursively sanitize values intended for JSONB to prevent Postgres errors + * like "unsupported Unicode escape sequence". This primarily removes the + * NUL character (\u0000) and other disallowed control characters from + * strings. Arrays and objects are traversed and sanitized deeply. + * @param {any} value + * @returns {any} + */ + sanitizeForJsonb: function (value) { + // Fast path for null/undefined and primitives that do not need changes + if (value === null || value === undefined) return value; + + // Strings: strip NUL and unsafe C0 control characters except common whitespace + if (typeof value === "string") { + // Build a sanitized string by excluding C0 control characters except + // horizontal tab (9), line feed (10), and carriage return (13). + let sanitized = ""; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code === 9 || code === 10 || code === 13 || code >= 0x20) { + sanitized += value[i]; + } + } + return sanitized; + } + + // Arrays: sanitize each element + if (Array.isArray(value)) { + return value.map((item) => this.sanitizeForJsonb(item)); + } + + // Dates: keep as ISO string + if (value instanceof Date) { + return value.toISOString(); + } + + // Objects: sanitize each property value + if (typeof value === "object") { + const result = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = this.sanitizeForJsonb(v); + } + return result; + } + + // Numbers, booleans, etc. + return value; + }, + client: function (connectionString = null) { return new pgsql.Client({ connectionString: connectionString || PGVector.connectionString(), @@ -362,9 +411,11 @@ const PGVector = { /** * Update or create a collection in the database - * @param {pgsql.Connection} connection - * @param {{id: number, vector: number[], metadata: Object}[]} submissions - * @param {string} namespace + * @param {Object} params + * @param {pgsql.Connection} params.connection + * @param {{id: number, vector: number[], metadata: Object}[]} params.submissions + * @param {string} params.namespace + * @param {number} params.dimensions * @returns {Promise} */ updateOrCreateCollection: async function ({ @@ -381,9 +432,10 @@ const PGVector = { await connection.query(`BEGIN`); for (const submission of submissions) { const embedding = `[${submission.vector.map(Number).join(",")}]`; // stringify the vector for pgvector + const sanitizedMetadata = this.sanitizeForJsonb(submission.metadata); await connection.query( `INSERT INTO "${PGVector.tableName()}" (id, namespace, embedding, metadata) VALUES ($1, $2, $3, $4)`, - [submission.id, namespace, embedding, submission.metadata] + [submission.id, namespace, embedding, sanitizedMetadata] ); } this.log(`Committing ${submissions.length} vectors to ${namespace}`); From 96bf1276969f4d1d2d1b7c97c59db7dadc81eb51 Mon Sep 17 00:00:00 2001 From: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:32:56 -0700 Subject: [PATCH 15/15] New Default System Prompt Variables (User ID, Workspace ID, & Workspace Name) (#4414) * Fix system prompt variable color logic by removing unused variable type from switch statement and adding new types. * Add workspace id, name and user id as default system prompt variables * Combine user and workspace variable evaluations into a single if statment, reducing redundant code. * minor refactor * add systemPromptVariable expandSystemPromptVariables test cases --------- Co-authored-by: timothycarambat --- .../VariableRow/index.jsx | 9 +- .../models/systemPromptVariables.test.js | 61 +++++++++++ server/models/systemPromptVariables.js | 101 +++++++++++++++--- server/utils/chats/index.js | 3 +- 4 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 server/__tests__/models/systemPromptVariables.test.js diff --git a/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx index ac4c3ea8..05a95c0b 100644 --- a/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx +++ b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx @@ -45,11 +45,16 @@ export default function VariableRow({ variable, onRefresh }) { bg: "bg-blue-600/20", text: "text-blue-400 light:text-blue-800", }; - case "dynamic": + case "user": return { bg: "bg-green-600/20", text: "text-green-400 light:text-green-800", }; + case "workspace": + return { + bg: "bg-cyan-600/20", + text: "text-cyan-400 light:text-cyan-800", + }; default: return { bg: "bg-yellow-600/20", @@ -81,7 +86,7 @@ export default function VariableRow({ variable, onRefresh }) { - {titleCase(variable.type)} + {titleCase(variable?.type ?? "static")} diff --git a/server/__tests__/models/systemPromptVariables.test.js b/server/__tests__/models/systemPromptVariables.test.js new file mode 100644 index 00000000..2220406e --- /dev/null +++ b/server/__tests__/models/systemPromptVariables.test.js @@ -0,0 +1,61 @@ +const { SystemPromptVariables } = require("../../models/systemPromptVariables"); +const prisma = require("../../utils/prisma"); + +const mockUser = { + id: 1, + username: "john.doe", + bio: "I am a test user", +}; + +const mockWorkspace = { + id: 1, + name: "Test Workspace", + slug: 'test-workspace', +}; + +const mockSystemPromptVariables = [ + { + id: 1, + key: "mystaticvariable", + value: "AnythingLLM testing runtime", + description: "A test variable", + type: "static", + userId: null, + }, +]; + +describe("SystemPromptVariables.expandSystemPromptVariables", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Mock just the Prisma actions since that is what is used by default values + prisma.system_prompt_variables.findMany = jest.fn().mockResolvedValue(mockSystemPromptVariables); + prisma.workspaces.findUnique = jest.fn().mockResolvedValue(mockWorkspace); + prisma.users.findUnique = jest.fn().mockResolvedValue(mockUser); + }); + + it("should expand user-defined system prompt variables", async () => { + const variables = await SystemPromptVariables.expandSystemPromptVariables("Hello {mystaticvariable}"); + expect(variables).toBe(`Hello ${mockSystemPromptVariables[0].value}`); + }); + + it("should expand workspace-defined system prompt variables", async () => { + const variables = await SystemPromptVariables.expandSystemPromptVariables("Hello {workspace.name}", null, mockWorkspace.id); + expect(variables).toBe(`Hello ${mockWorkspace.name}`); + }); + + it("should expand user-defined system prompt variables", async () => { + const variables = await SystemPromptVariables.expandSystemPromptVariables("Hello {user.name}", mockUser.id); + expect(variables).toBe(`Hello ${mockUser.username}`); + }); + + it("should work with any combination of variables", async () => { + const variables = await SystemPromptVariables.expandSystemPromptVariables("Hello {mystaticvariable} {workspace.name} {user.name}", mockUser.id, mockWorkspace.id); + expect(variables).toBe(`Hello ${mockSystemPromptVariables[0].value} ${mockWorkspace.name} ${mockUser.username}`); + }); + + it('should fail gracefully with invalid variables that are undefined for any reason', async () => { + // Undefined sub-fields on valid classes are push to a placeholder [Class prop]. This is expected behavior. + const variables = await SystemPromptVariables.expandSystemPromptVariables("Hello {invalid.variable} {user.password} the current user is {user.name} on workspace id #{workspace.id}", null, null); + expect(variables).toBe("Hello {invalid.variable} [User password] the current user is [User name] on workspace id #[Workspace ID]"); + }); +}); \ No newline at end of file diff --git a/server/models/systemPromptVariables.js b/server/models/systemPromptVariables.js index bfe94f19..5e63c8a1 100644 --- a/server/models/systemPromptVariables.js +++ b/server/models/systemPromptVariables.js @@ -7,13 +7,13 @@ const moment = require("moment"); * @property {string} key * @property {string|function} value * @property {string} description - * @property {'system'|'user'|'static'} type + * @property {'system'|'user'|'workspace'|'static'} type * @property {number} userId * @property {boolean} multiUserRequired */ const SystemPromptVariables = { - VALID_TYPES: ["user", "system", "static"], + VALID_TYPES: ["user", "workspace", "system", "static"], DEFAULT_VARIABLES: [ { key: "time", @@ -36,6 +36,16 @@ const SystemPromptVariables = { type: "system", multiUserRequired: false, }, + { + key: "user.id", + value: (userId = null) => { + if (!userId) return "[User ID]"; + return userId; + }, + description: "Current user's ID", + type: "user", + multiUserRequired: true, + }, { key: "user.name", value: async (userId = null) => { @@ -74,6 +84,30 @@ const SystemPromptVariables = { type: "user", multiUserRequired: true, }, + { + key: "workspace.id", + value: (workspaceId = null) => { + if (!workspaceId) return "[Workspace ID]"; + return workspaceId; + }, + description: "Current workspace's ID", + type: "workspace", + multiUserRequired: false, + }, + { + key: "workspace.name", + value: async (workspaceId = null) => { + if (!workspaceId) return "[Workspace name]"; + const workspace = await prisma.workspaces.findUnique({ + where: { id: Number(workspaceId) }, + select: { name: true }, + }); + return workspace?.name || "[Workspace name is empty or unknown]"; + }, + description: "Current workspace's name", + type: "workspace", + multiUserRequired: false, + }, ], /** @@ -183,12 +217,17 @@ const SystemPromptVariables = { }, /** - * Injects variables into a string based on the user ID (if provided) and the variables available + * Injects variables into a string based on the user ID and workspace ID (if provided) and the variables available * @param {string} str - the input string to expand variables into * @param {number|null} userId - the user ID to use for dynamic variables + * @param {number|null} workspaceId - the workspace ID to use for workspace variables * @returns {Promise} */ - expandSystemPromptVariables: async function (str, userId = null) { + expandSystemPromptVariables: async function ( + str, + userId = null, + workspaceId = null + ) { if (!str) return str; try { @@ -202,26 +241,62 @@ const SystemPromptVariables = { for (const match of matches) { const key = match.substring(1, match.length - 1); // Remove { and } - // Handle `user.X` variables with current user's data - if (key.startsWith("user.")) { - const userProp = key.split(".")[1]; + // Determine if the variable is a class-based variable (workspace.X or user.X) + const isWorkspaceOrUserVariable = ["workspace.", "user."].some( + (prefix) => key.startsWith(prefix) + ); + + // Handle class-based variables with current workspace's or user's data + if (isWorkspaceOrUserVariable) { + let variableTypeDisplay; + if (key.startsWith("workspace.")) variableTypeDisplay = "Workspace"; + else if (key.startsWith("user.")) variableTypeDisplay = "User"; + else throw new Error(`Invalid class-based variable: ${key}`); + + // Get the property name after the prefix + const prop = key.split(".")[1]; const variable = allVariables.find((v) => v.key === key); + // If the variable is a function, call it to get the current value if (variable && typeof variable.value === "function") { + // If the variable is an async function, call it to get the current value if (variable.value.constructor.name === "AsyncFunction") { + let value; try { - const value = await variable.value(userId); - result = result.replace(match, value); + if (variableTypeDisplay === "Workspace") + value = await variable.value(workspaceId); + else if (variableTypeDisplay === "User") + value = await variable.value(userId); + else throw new Error(`Invalid class-based variable: ${key}`); } catch (error) { - console.error(`Error processing user variable ${key}:`, error); - result = result.replace(match, `[User ${userProp}]`); + console.error( + `Error processing ${variableTypeDisplay} variable ${key}:`, + error + ); + value = `[${variableTypeDisplay} ${prop}]`; } + result = result.replace(match, value); } else { - const value = variable.value(); + let value; + try { + // Call the variable function with the appropriate workspace or user ID + if (variableTypeDisplay === "Workspace") + value = variable.value(workspaceId); + else if (variableTypeDisplay === "User") + value = variable.value(userId); + else throw new Error(`Invalid class-based variable: ${key}`); + } catch (error) { + console.error( + `Error processing ${variableTypeDisplay} variable ${key}:`, + error + ); + value = `[${variableTypeDisplay} ${prop}]`; + } result = result.replace(match, value); } } else { - result = result.replace(match, `[User ${userProp}]`); + // If the variable is not a function, replace the match with the variable value + result = result.replace(match, `[${variableTypeDisplay} ${prop}]`); } continue; } diff --git a/server/utils/chats/index.js b/server/utils/chats/index.js index 658302f7..aaf46bc2 100644 --- a/server/utils/chats/index.js +++ b/server/utils/chats/index.js @@ -94,7 +94,8 @@ async function chatPrompt(workspace, user = null) { "Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed."; return await SystemPromptVariables.expandSystemPromptVariables( basePrompt, - user?.id + user?.id, + workspace?.id ); }