From 591a019766b2fda20b313d02db92cbe54e5fc373 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 29 Jan 2025 19:46:58 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=84=E2=80=8D=E2=99=82=EF=B8=8F=20refac?= =?UTF-8?q?tor:=20Optimize=20Reasoning=20UI=20&=20Token=20Streaming=20(#55?= =?UTF-8?q?46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Implement Show Thinking feature; refactor: testing thinking render optimizations * ✨ feat: Refactor Thinking component styles and enhance Markdown rendering * chore: add back removed code, revert type changes * chore: Add back resetCounter effect to Markdown component for improved code block indexing * chore: bump @librechat/agents and google langchain packages * WIP: reasoning type updates * WIP: first pass, reasoning content blocks * chore: revert code * chore: bump @librechat/agents * refactor: optimize reasoning tag handling * style: ul indent padding * feat: add Reasoning component to handle reasoning display * feat: first pass, content reasoning part styling * refactor: add content placeholder for endpoints using new stream handler * refactor: only cache messages when requesting stream audio * fix: circular dep. * fix: add default param * refactor: tts, only request after message stream, fix chrome autoplay * style: update label for submitting state and add localization for 'Thinking...' * fix: improve global audio pause logic and reset active run ID * fix: handle artifact edge cases * fix: remove unnecessary console log from artifact update test * feat: add support for continued message handling with new streaming method --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com> --- api/app/clients/BaseClient.js | 21 +- api/app/clients/OpenAIClient.js | 116 +- api/app/clients/PluginsClient.js | 13 - api/config/index.js | 15 + api/package.json | 6 +- api/server/controllers/AskController.js | 34 +- api/server/controllers/EditController.js | 82 +- api/server/controllers/agents/callbacks.js | 17 +- api/server/controllers/assistants/chatV2.js | 12 - api/server/routes/ask/gptPlugins.js | 17 +- api/server/routes/edit/gptPlugins.js | 16 +- api/server/routes/messages.js | 2 +- api/server/services/Artifacts/update.js | 34 +- api/server/services/Artifacts/update.spec.js | 55 +- api/server/services/Files/Audio/TTSService.js | 2 +- .../services/Files/Audio/streamAudio.js | 22 +- .../services/Files/Audio/streamAudio.spec.js | 10 +- api/server/services/Runs/StreamRunManager.js | 20 +- api/server/utils/handleText.js | 7 +- client/public/assets/silence.mp3 | Bin 0 -> 36494 bytes client/src/App.jsx | 9 + client/src/Providers/MessageContext.tsx | 2 + client/src/components/Artifacts/Artifact.tsx | 4 - .../Artifacts/ArtifactCodeEditor.tsx | 1 - client/src/components/Artifacts/Thinking.tsx | 99 +- client/src/components/Audio/TTS.tsx | 4 +- .../src/components/Chat/Input/StreamAudio.tsx | 12 +- .../Chat/Messages/Content/ContentParts.tsx | 32 +- .../Chat/Messages/Content/Markdown.tsx | 199 +-- .../components/Chat/Messages/Content/Part.tsx | 7 + .../Chat/Messages/Content/Parts/Reasoning.tsx | 34 + .../Chat/Messages/Content/Parts/Text.tsx | 2 +- .../components/Chat/Messages/HoverButtons.tsx | 4 +- .../components/Chat/Messages/MessageAudio.tsx | 1 - .../components/Nav/SettingsTabs/Chat/Chat.tsx | 4 + .../Nav/SettingsTabs/Chat/ShowThinking.tsx | 37 + client/src/hooks/Audio/usePauseGlobalAudio.ts | 7 +- client/src/hooks/Chat/useChatFunctions.ts | 26 +- .../hooks/Input/useTextToSpeechExternal.ts | 11 +- client/src/hooks/SSE/useStepHandler.ts | 37 + client/src/localization/languages/Eng.ts | 4 + client/src/store/settings.ts | 2 +- client/src/style.css | 2 +- config/translations/streamAudioTest.ts | 134 -- package-lock.json | 1302 ++++++++++++++--- packages/data-provider/src/types/agents.ts | 40 +- .../data-provider/src/types/assistants.ts | 1 + packages/data-provider/src/types/runs.ts | 1 + 48 files changed, 1792 insertions(+), 727 deletions(-) create mode 100644 client/public/assets/silence.mp3 create mode 100644 client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx delete mode 100644 config/translations/streamAudioTest.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 6cda4471421..6ddcaa97219 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -7,15 +7,12 @@ const { EModelEndpoint, ErrorTypes, Constants, - CacheKeys, - Time, } = require('librechat-data-provider'); const { getMessages, saveMessage, updateMessage, saveConvo } = require('~/models'); const { addSpaceIfNeeded, isEnabled } = require('~/server/utils'); const { truncateToolCallOutputs } = require('./prompts'); const checkBalance = require('~/models/checkBalance'); const { getFiles } = require('~/models/File'); -const { getLogStores } = require('~/cache'); const TextStream = require('./TextStream'); const { logger } = require('~/config'); @@ -54,6 +51,12 @@ class BaseClient { this.outputTokensKey = 'completion_tokens'; /** @type {Set} */ this.savedMessageIds = new Set(); + /** + * Flag to determine if the client re-submitted the latest assistant message. + * @type {boolean | undefined} */ + this.continued; + /** @type {TMessage[]} */ + this.currentMessages = []; } setOptions() { @@ -589,6 +592,7 @@ class BaseClient { } else { latestMessage.text = generation; } + this.continued = true; } else { this.currentMessages.push(userMessage); } @@ -720,17 +724,6 @@ class BaseClient { this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user); this.savedMessageIds.add(responseMessage.messageId); - if (responseMessage.text) { - const messageCache = getLogStores(CacheKeys.MESSAGES); - messageCache.set( - responseMessageId, - { - text: responseMessage.text, - complete: true, - }, - Time.FIVE_MINUTES, - ); - } delete responseMessage.tokenCount; return responseMessage; } diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 89b938b8582..8b71dcbc52c 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1,6 +1,7 @@ const OpenAI = require('openai'); const { OllamaClient } = require('./OllamaClient'); const { HttpsProxyAgent } = require('https-proxy-agent'); +const { SplitStreamHandler, GraphEvents } = require('@librechat/agents'); const { Constants, ImageDetail, @@ -28,17 +29,17 @@ const { createContextHandlers, } = require('./prompts'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); +const { addSpaceIfNeeded, isEnabled, sleep } = require('~/server/utils'); const Tokenizer = require('~/server/services/Tokenizer'); const { spendTokens } = require('~/models/spendTokens'); -const { isEnabled, sleep } = require('~/server/utils'); const { handleOpenAIErrors } = require('./tools/util'); const { createLLM, RunManager } = require('./llm'); +const { logger, sendEvent } = require('~/config'); const ChatGPTClient = require('./ChatGPTClient'); const { summaryBuffer } = require('./memory'); const { runTitleChain } = require('./chains'); const { tokenSplit } = require('./document'); const BaseClient = require('./BaseClient'); -const { logger } = require('~/config'); class OpenAIClient extends BaseClient { constructor(apiKey, options = {}) { @@ -65,6 +66,8 @@ class OpenAIClient extends BaseClient { this.usage; /** @type {boolean|undefined} */ this.isO1Model; + /** @type {SplitStreamHandler | undefined} */ + this.streamHandler; } // TODO: PluginsClient calls this 3x, unneeded @@ -1064,11 +1067,36 @@ ${convo} }); } + getStreamText() { + if (!this.streamHandler) { + return ''; + } + + const reasoningTokens = + this.streamHandler.reasoningTokens.length > 0 + ? `:::thinking\n${this.streamHandler.reasoningTokens.join('')}\n:::\n` + : ''; + + return `${reasoningTokens}${this.streamHandler.tokens.join('')}`; + } + + getMessageMapMethod() { + /** + * @param {TMessage} msg + */ + return (msg) => { + if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) { + msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim(); + } + + return msg; + }; + } + async chatCompletion({ payload, onProgress, abortController = null }) { let error = null; + let intermediateReply = []; const errorCallback = (err) => (error = err); - const intermediateReply = []; - const reasoningTokens = []; try { if (!abortController) { abortController = new AbortController(); @@ -1266,6 +1294,19 @@ ${convo} reasoningKey = 'reasoning'; } + this.streamHandler = new SplitStreamHandler({ + reasoningKey, + accumulate: true, + runId: this.responseMessageId, + handlers: { + [GraphEvents.ON_RUN_STEP]: (event) => sendEvent(this.options.res, event), + [GraphEvents.ON_MESSAGE_DELTA]: (event) => sendEvent(this.options.res, event), + [GraphEvents.ON_REASONING_DELTA]: (event) => sendEvent(this.options.res, event), + }, + }); + + intermediateReply = this.streamHandler.tokens; + if (modelOptions.stream) { streamPromise = new Promise((resolve) => { streamResolve = resolve; @@ -1292,41 +1333,36 @@ ${convo} } if (typeof finalMessage.content !== 'string' || finalMessage.content.trim() === '') { - finalChatCompletion.choices[0].message.content = intermediateReply.join(''); + finalChatCompletion.choices[0].message.content = this.streamHandler.tokens.join(''); } }) .on('finalMessage', (message) => { if (message?.role !== 'assistant') { - stream.messages.push({ role: 'assistant', content: intermediateReply.join('') }); + stream.messages.push({ + role: 'assistant', + content: this.streamHandler.tokens.join(''), + }); UnexpectedRoleError = true; } }); - let reasoningCompleted = false; - for await (const chunk of stream) { - if (chunk?.choices?.[0]?.delta?.[reasoningKey]) { - if (reasoningTokens.length === 0) { - const thinkingDirective = '\n'; - intermediateReply.push(thinkingDirective); - reasoningTokens.push(thinkingDirective); - onProgress(thinkingDirective); - } - const reasoning_content = chunk?.choices?.[0]?.delta?.[reasoningKey] || ''; - intermediateReply.push(reasoning_content); - reasoningTokens.push(reasoning_content); - onProgress(reasoning_content); - } - - const token = chunk?.choices?.[0]?.delta?.content || ''; - if (!reasoningCompleted && reasoningTokens.length > 0 && token) { - reasoningCompleted = true; - const separatorTokens = '\n\n'; - reasoningTokens.push(separatorTokens); - onProgress(separatorTokens); - } + if (this.continued === true) { + const latestText = addSpaceIfNeeded( + this.currentMessages[this.currentMessages.length - 1]?.text ?? '', + ); + this.streamHandler.handle({ + choices: [ + { + delta: { + content: latestText, + }, + }, + ], + }); + } - intermediateReply.push(token); - onProgress(token); + for await (const chunk of stream) { + this.streamHandler.handle(chunk); if (abortController.signal.aborted) { stream.controller.abort(); break; @@ -1369,7 +1405,7 @@ ${convo} if (!Array.isArray(choices) || choices.length === 0) { logger.warn('[OpenAIClient] Chat completion response has no choices'); - return intermediateReply.join(''); + return this.streamHandler.tokens.join(''); } const { message, finish_reason } = choices[0] ?? {}; @@ -1379,11 +1415,11 @@ ${convo} if (!message) { logger.warn('[OpenAIClient] Message is undefined in chatCompletion response'); - return intermediateReply.join(''); + return this.streamHandler.tokens.join(''); } if (typeof message.content !== 'string' || message.content.trim() === '') { - const reply = intermediateReply.join(''); + const reply = this.streamHandler.tokens.join(''); logger.debug( '[OpenAIClient] chatCompletion: using intermediateReply due to empty message.content', { intermediateReply: reply }, @@ -1391,8 +1427,18 @@ ${convo} return reply; } - if (reasoningTokens.length > 0 && this.options.context !== 'title') { - return reasoningTokens.join('') + message.content; + if ( + this.streamHandler.reasoningTokens.length > 0 && + this.options.context !== 'title' && + !message.content.startsWith('') + ) { + return this.getStreamText(); + } else if ( + this.streamHandler.reasoningTokens.length > 0 && + this.options.context !== 'title' && + message.content.startsWith('') + ) { + return message.content.replace('', ':::thinking').replace('', ':::'); } return message.content; diff --git a/api/app/clients/PluginsClient.js b/api/app/clients/PluginsClient.js index 0e518bfea02..c15258f98c1 100644 --- a/api/app/clients/PluginsClient.js +++ b/api/app/clients/PluginsClient.js @@ -1,5 +1,4 @@ const OpenAIClient = require('./OpenAIClient'); -const { CacheKeys, Time } = require('librechat-data-provider'); const { CallbackManager } = require('@langchain/core/callbacks/manager'); const { BufferMemory, ChatMessageHistory } = require('langchain/memory'); const { addImages, buildErrorInput, buildPromptPrefix } = require('./output_parsers'); @@ -11,7 +10,6 @@ const checkBalance = require('~/models/checkBalance'); const { isEnabled } = require('~/server/utils'); const { extractBaseURL } = require('~/utils'); const { loadTools } = require('./tools/util'); -const { getLogStores } = require('~/cache'); const { logger } = require('~/config'); class PluginsClient extends OpenAIClient { @@ -256,17 +254,6 @@ class PluginsClient extends OpenAIClient { } this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user); - if (responseMessage.text) { - const messageCache = getLogStores(CacheKeys.MESSAGES); - messageCache.set( - responseMessage.messageId, - { - text: responseMessage.text, - complete: true, - }, - Time.FIVE_MINUTES, - ); - } delete responseMessage.tokenCount; return { ...responseMessage, ...result }; } diff --git a/api/config/index.js b/api/config/index.js index c66d92ae434..c2b21cfc079 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -16,7 +16,22 @@ async function getMCPManager() { return mcpManager; } +/** + * Sends message data in Server Sent Events format. + * @param {ServerResponse} res - The server response. + * @param {{ data: string | Record, event?: string }} event - The message event. + * @param {string} event.event - The type of event. + * @param {string} event.data - The message to be sent. + */ +const sendEvent = (res, event) => { + if (typeof event.data === 'string' && event.data.length === 0) { + return; + } + res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`); +}; + module.exports = { logger, + sendEvent, getMCPManager, }; diff --git a/api/package.json b/api/package.json index fe8b1f1f280..f7104d89445 100644 --- a/api/package.json +++ b/api/package.json @@ -41,10 +41,10 @@ "@keyv/redis": "^2.8.1", "@langchain/community": "^0.3.14", "@langchain/core": "^0.3.18", - "@langchain/google-genai": "^0.1.6", - "@langchain/google-vertexai": "^0.1.6", + "@langchain/google-genai": "^0.1.7", + "@langchain/google-vertexai": "^0.1.8", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^1.9.94", + "@librechat/agents": "^1.9.97", "@waylaidwanderer/fetch-event-source": "^3.0.1", "axios": "^1.7.7", "bcryptjs": "^2.4.3", diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js index 6534d6b3b32..b952ab00426 100644 --- a/api/server/controllers/AskController.js +++ b/api/server/controllers/AskController.js @@ -1,8 +1,6 @@ -const throttle = require('lodash/throttle'); -const { getResponseSender, Constants, CacheKeys, Time } = require('librechat-data-provider'); +const { getResponseSender, Constants } = require('librechat-data-provider'); const { createAbortController, handleAbortError } = require('~/server/middleware'); const { sendMessage, createOnProgress } = require('~/server/utils'); -const { getLogStores } = require('~/cache'); const { saveMessage } = require('~/models'); const { logger } = require('~/config'); @@ -57,33 +55,9 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { try { const { client } = await initializeClient({ req, res, endpointOption }); - const messageCache = getLogStores(CacheKeys.MESSAGES); - const { onProgress: progressCallback, getPartialText } = createOnProgress({ - onProgress: throttle( - ({ text: partialText }) => { - /* - const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true; - messageCache.set(responseMessageId, { - messageId: responseMessageId, - sender, - conversationId, - parentMessageId: overrideParentMessageId ?? userMessageId, - text: partialText, - model: client.modelOptions.model, - unfinished, - error: false, - user, - }, Time.FIVE_MINUTES); - */ - - messageCache.set(responseMessageId, partialText, Time.FIVE_MINUTES); - }, - 3000, - { trailing: false }, - ), - }); + const { onProgress: progressCallback, getPartialText } = createOnProgress(); - getText = getPartialText; + getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText; const getAbortData = () => ({ sender, @@ -91,7 +65,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { userMessagePromise, messageId: responseMessageId, parentMessageId: overrideParentMessageId ?? userMessageId, - text: getPartialText(), + text: getText(), userMessage, promptTokens, }); diff --git a/api/server/controllers/EditController.js b/api/server/controllers/EditController.js index 28fe2c4fea1..ec618eabcf8 100644 --- a/api/server/controllers/EditController.js +++ b/api/server/controllers/EditController.js @@ -1,8 +1,6 @@ -const throttle = require('lodash/throttle'); -const { getResponseSender, CacheKeys, Time } = require('librechat-data-provider'); +const { getResponseSender } = require('librechat-data-provider'); const { createAbortController, handleAbortError } = require('~/server/middleware'); const { sendMessage, createOnProgress } = require('~/server/utils'); -const { getLogStores } = require('~/cache'); const { saveMessage } = require('~/models'); const { logger } = require('~/config'); @@ -53,61 +51,43 @@ const EditController = async (req, res, next, initializeClient) => { } }; - const messageCache = getLogStores(CacheKeys.MESSAGES); const { onProgress: progressCallback, getPartialText } = createOnProgress({ generation, - onProgress: throttle( - ({ text: partialText }) => { - /* - const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true; - { - messageId: responseMessageId, - sender, - conversationId, - parentMessageId: overrideParentMessageId ?? userMessageId, - text: partialText, - model: endpointOption.modelOptions.model, - unfinished, - isEdited: true, - error: false, - user, - } */ - messageCache.set(responseMessageId, partialText, Time.FIVE_MINUTES); - }, - 3000, - { trailing: false }, - ), }); - const getAbortData = () => ({ - conversationId, - userMessagePromise, - messageId: responseMessageId, - sender, - parentMessageId: overrideParentMessageId ?? userMessageId, - text: getPartialText(), - userMessage, - promptTokens, - }); + let getText; - const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData); + try { + const { client } = await initializeClient({ req, res, endpointOption }); - res.on('close', () => { - logger.debug('[EditController] Request closed'); - if (!abortController) { - return; - } else if (abortController.signal.aborted) { - return; - } else if (abortController.requestCompleted) { - return; - } + getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText; - abortController.abort(); - logger.debug('[EditController] Request aborted on close'); - }); + const getAbortData = () => ({ + conversationId, + userMessagePromise, + messageId: responseMessageId, + sender, + parentMessageId: overrideParentMessageId ?? userMessageId, + text: getText(), + userMessage, + promptTokens, + }); - try { - const { client } = await initializeClient({ req, res, endpointOption }); + const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData); + + res.on('close', () => { + logger.debug('[EditController] Request closed'); + if (!abortController) { + return; + } else if (abortController.signal.aborted) { + return; + } else if (abortController.requestCompleted) { + return; + } + + abortController.abort(); + logger.debug('[EditController] Request aborted on close'); + }); let response = await client.sendMessage(text, { user, @@ -153,7 +133,7 @@ const EditController = async (req, res, next, initializeClient) => { ); } } catch (error) { - const partialText = getPartialText(); + const partialText = getText(); handleAbortError(res, req, error, { partialText, conversationId, diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 706b9db83d7..53b45d3b6d6 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -10,7 +10,7 @@ const { const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { saveBase64Image } = require('~/server/services/Files/process'); const { loadAuthValues } = require('~/app/clients/tools/util'); -const { logger } = require('~/config'); +const { logger, sendEvent } = require('~/config'); /** @typedef {import('@librechat/agents').Graph} Graph */ /** @typedef {import('@librechat/agents').EventHandler} EventHandler */ @@ -21,20 +21,6 @@ const { logger } = require('~/config'); /** @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator */ /** @typedef {import('@librechat/agents').GraphEvents} GraphEvents */ -/** - * Sends message data in Server Sent Events format. - * @param {ServerResponse} res - The server response. - * @param {{ data: string | Record, event?: string }} event - The message event. - * @param {string} event.event - The type of event. - * @param {string} event.data - The message to be sent. - */ -const sendEvent = (res, event) => { - if (typeof event.data === 'string' && event.data.length === 0) { - return; - } - res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`); -}; - class ModelEndHandler { /** * @param {Array} collectedUsage @@ -322,7 +308,6 @@ function createToolEndCallback({ req, res, artifactPromises }) { } module.exports = { - sendEvent, getDefaultHandlers, createToolEndCallback, }; diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index 047a413433a..24a8e38fa4d 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -397,18 +397,6 @@ const chatV2 = async (req, res) => { response = streamRunManager; response.text = streamRunManager.intermediateText; - - if (response.text) { - const messageCache = getLogStores(CacheKeys.MESSAGES); - messageCache.set( - responseMessageId, - { - complete: true, - text: response.text, - }, - Time.FIVE_MINUTES, - ); - } }; await processRun(); diff --git a/api/server/routes/ask/gptPlugins.js b/api/server/routes/ask/gptPlugins.js index 8408e7a5d6d..036654f845b 100644 --- a/api/server/routes/ask/gptPlugins.js +++ b/api/server/routes/ask/gptPlugins.js @@ -1,11 +1,9 @@ const express = require('express'); -const throttle = require('lodash/throttle'); -const { getResponseSender, Constants, CacheKeys, Time } = require('librechat-data-provider'); +const { getResponseSender, Constants } = require('librechat-data-provider'); const { initializeClient } = require('~/server/services/Endpoints/gptPlugins'); const { sendMessage, createOnProgress } = require('~/server/utils'); const { addTitle } = require('~/server/services/Endpoints/openAI'); const { saveMessage, updateMessage } = require('~/models'); -const { getLogStores } = require('~/cache'); const { handleAbort, createAbortController, @@ -72,15 +70,6 @@ router.post( } }; - const messageCache = getLogStores(CacheKeys.MESSAGES); - const throttledCacheSet = throttle( - (text) => { - messageCache.set(responseMessageId, text, Time.FIVE_MINUTES); - }, - 3000, - { trailing: false }, - ); - let streaming = null; let timer = null; @@ -89,13 +78,11 @@ router.post( sendIntermediateMessage, getPartialText, } = createOnProgress({ - onProgress: ({ text: partialText }) => { + onProgress: () => { if (timer) { clearTimeout(timer); } - throttledCacheSet(partialText); - streaming = new Promise((resolve) => { timer = setTimeout(() => { resolve(); diff --git a/api/server/routes/edit/gptPlugins.js b/api/server/routes/edit/gptPlugins.js index cd4e575e836..5547a1fcdf9 100644 --- a/api/server/routes/edit/gptPlugins.js +++ b/api/server/routes/edit/gptPlugins.js @@ -1,6 +1,5 @@ const express = require('express'); -const throttle = require('lodash/throttle'); -const { getResponseSender, CacheKeys, Time } = require('librechat-data-provider'); +const { getResponseSender } = require('librechat-data-provider'); const { setHeaders, handleAbort, @@ -14,7 +13,6 @@ const { const { sendMessage, createOnProgress, formatSteps, formatAction } = require('~/server/utils'); const { initializeClient } = require('~/server/services/Endpoints/gptPlugins'); const { saveMessage, updateMessage } = require('~/models'); -const { getLogStores } = require('~/cache'); const { validateTools } = require('~/app'); const { logger } = require('~/config'); @@ -80,26 +78,16 @@ router.post( } }; - const messageCache = getLogStores(CacheKeys.MESSAGES); - const throttledCacheSet = throttle( - (text) => { - messageCache.set(responseMessageId, text, Time.FIVE_MINUTES); - }, - 3000, - { trailing: false }, - ); - const { onProgress: progressCallback, sendIntermediateMessage, getPartialText, } = createOnProgress({ generation, - onProgress: ({ text: partialText }) => { + onProgress: () => { if (plugin.loading === true) { plugin.loading = false; } - throttledCacheSet(partialText); }, }); diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 770cb0f67e5..54c4aab1c2d 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -21,7 +21,7 @@ router.post('/artifact/:messageId', async (req, res) => { const { messageId } = req.params; const { index, original, updated } = req.body; - if (typeof index !== 'number' || index < 0 || !original || !updated) { + if (typeof index !== 'number' || index < 0 || original == null || updated == null) { return res.status(400).json({ error: 'Invalid request parameters' }); } diff --git a/api/server/services/Artifacts/update.js b/api/server/services/Artifacts/update.js index f17c2c0b678..69cb4bb5c43 100644 --- a/api/server/services/Artifacts/update.js +++ b/api/server/services/Artifacts/update.js @@ -57,14 +57,42 @@ const findAllArtifacts = (message) => { const replaceArtifactContent = (originalText, artifact, original, updated) => { const artifactContent = artifact.text.substring(artifact.start, artifact.end); - const relativeIndex = artifactContent.indexOf(original); + + // Find boundaries between ARTIFACT_START and ARTIFACT_END + const contentStart = artifactContent.indexOf('\n', artifactContent.indexOf(ARTIFACT_START)) + 1; + const contentEnd = artifactContent.lastIndexOf(ARTIFACT_END); + + if (contentStart === -1 || contentEnd === -1) { + return null; + } + + // Check if there are code blocks + const codeBlockStart = artifactContent.indexOf('```\n', contentStart); + const codeBlockEnd = artifactContent.lastIndexOf('\n```', contentEnd); + + // Determine where to look for the original content + let searchStart, searchEnd; + if (codeBlockStart !== -1 && codeBlockEnd !== -1) { + // If code blocks exist, search between them + searchStart = codeBlockStart + 4; // after ```\n + searchEnd = codeBlockEnd; + } else { + // Otherwise search in the whole artifact content + searchStart = contentStart; + searchEnd = contentEnd; + } + + const innerContent = artifactContent.substring(searchStart, searchEnd); + // Remove trailing newline from original for comparison + const originalTrimmed = original.replace(/\n$/, ''); + const relativeIndex = innerContent.indexOf(originalTrimmed); if (relativeIndex === -1) { return null; } - const absoluteIndex = artifact.start + relativeIndex; - const endText = originalText.substring(absoluteIndex + original.length); + const absoluteIndex = artifact.start + searchStart + relativeIndex; + const endText = originalText.substring(absoluteIndex + originalTrimmed.length); const hasTrailingNewline = endText.startsWith('\n'); const updatedText = diff --git a/api/server/services/Artifacts/update.spec.js b/api/server/services/Artifacts/update.spec.js index 8008e553baf..2f5b9d7bf64 100644 --- a/api/server/services/Artifacts/update.spec.js +++ b/api/server/services/Artifacts/update.spec.js @@ -260,8 +260,61 @@ console.log(greeting);`; codeExample, 'updated content', ); - console.log(result); expect(result).toMatch(/id="2".*updated content/s); expect(result).toMatch(new RegExp(`${ARTIFACT_START}.*updated content.*${ARTIFACT_END}`, 's')); }); + + test('should handle empty content in artifact without code blocks', () => { + const artifactText = `${ARTIFACT_START}\n\n${ARTIFACT_END}`; + const artifact = { + start: 0, + end: artifactText.length, + text: artifactText, + source: 'text', + }; + + const result = replaceArtifactContent(artifactText, artifact, '', 'new content'); + expect(result).toBe(`${ARTIFACT_START}\nnew content\n${ARTIFACT_END}`); + }); + + test('should handle empty content in artifact with code blocks', () => { + const artifactText = createArtifactText({ content: '' }); + const artifact = { + start: 0, + end: artifactText.length, + text: artifactText, + source: 'text', + }; + + const result = replaceArtifactContent(artifactText, artifact, '', 'new content'); + expect(result).toMatch(/```\nnew content\n```/); + }); + + test('should handle content with trailing newline in code blocks', () => { + const contentWithNewline = 'console.log("test")\n'; + const message = { + text: `Some prefix text\n${createArtifactText({ + content: contentWithNewline, + })}\nSome suffix text`, + }; + + const artifacts = findAllArtifacts(message); + expect(artifacts).toHaveLength(1); + + const result = replaceArtifactContent( + message.text, + artifacts[0], + contentWithNewline, + 'updated content', + ); + + // Should update the content and preserve artifact structure + expect(result).toContain('```\nupdated content\n```'); + // Should preserve surrounding text + expect(result).toMatch(/^Some prefix text\n/); + expect(result).toMatch(/\nSome suffix text$/); + // Should not have extra newlines + expect(result).not.toContain('\n\n```'); + expect(result).not.toContain('```\n\n'); + }); }); diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index bfb90843da4..cd718fdfc15 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -364,7 +364,7 @@ class TTSService { shouldContinue = false; }); - const processChunks = createChunkProcessor(req.body.messageId); + const processChunks = createChunkProcessor(req.user.id, req.body.messageId); try { while (shouldContinue) { diff --git a/api/server/services/Files/Audio/streamAudio.js b/api/server/services/Files/Audio/streamAudio.js index 7b6bef03f84..ac046e68a65 100644 --- a/api/server/services/Files/Audio/streamAudio.js +++ b/api/server/services/Files/Audio/streamAudio.js @@ -1,4 +1,5 @@ -const { CacheKeys, findLastSeparatorIndex, SEPARATORS } = require('librechat-data-provider'); +const { CacheKeys, findLastSeparatorIndex, SEPARATORS, Time } = require('librechat-data-provider'); +const { getMessage } = require('~/models/Message'); const { getLogStores } = require('~/cache'); /** @@ -47,10 +48,11 @@ const MAX_NOT_FOUND_COUNT = 6; const MAX_NO_CHANGE_COUNT = 10; /** + * @param {string} user * @param {string} messageId * @returns {() => Promise<{ text: string, isFinished: boolean }[]>} */ -function createChunkProcessor(messageId) { +function createChunkProcessor(user, messageId) { let notFoundCount = 0; let noChangeCount = 0; let processedText = ''; @@ -73,15 +75,27 @@ function createChunkProcessor(messageId) { } /** @type { string | { text: string; complete: boolean } } */ - const message = await messageCache.get(messageId); + let message = await messageCache.get(messageId); + if (!message) { + message = await getMessage({ user, messageId }); + } if (!message) { notFoundCount++; return []; + } else { + messageCache.set( + messageId, + { + text: message.text, + complete: true, + }, + Time.FIVE_MINUTES, + ); } const text = typeof message === 'string' ? message : message.text; - const complete = typeof message === 'string' ? false : message.complete; + const complete = typeof message === 'string' ? false : message.complete ?? true; if (text === processedText) { noChangeCount++; diff --git a/api/server/services/Files/Audio/streamAudio.spec.js b/api/server/services/Files/Audio/streamAudio.spec.js index 501e252c14b..e76c0849c7f 100644 --- a/api/server/services/Files/Audio/streamAudio.spec.js +++ b/api/server/services/Files/Audio/streamAudio.spec.js @@ -3,6 +3,13 @@ const { createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); jest.mock('keyv'); const globalCache = {}; +jest.mock('~/models/Message', () => { + return { + getMessage: jest.fn().mockImplementation((messageId) => { + return globalCache[messageId] || null; + }), + }; +}); jest.mock('~/cache/getLogStores', () => { return jest.fn().mockImplementation(() => { const EventEmitter = require('events'); @@ -56,9 +63,10 @@ describe('processChunks', () => { jest.resetAllMocks(); mockMessageCache = { get: jest.fn(), + set: jest.fn(), }; require('~/cache/getLogStores').mockReturnValue(mockMessageCache); - processChunks = createChunkProcessor('message-id'); + processChunks = createChunkProcessor('userId', 'message-id'); }); it('should return an empty array when the message is not found', async () => { diff --git a/api/server/services/Runs/StreamRunManager.js b/api/server/services/Runs/StreamRunManager.js index ae00659983e..4bab7326bb0 100644 --- a/api/server/services/Runs/StreamRunManager.js +++ b/api/server/services/Runs/StreamRunManager.js @@ -1,19 +1,15 @@ -const throttle = require('lodash/throttle'); const { - Time, - CacheKeys, + Constants, StepTypes, ContentTypes, ToolCallTypes, MessageContentTypes, AssistantStreamEvents, - Constants, } = require('librechat-data-provider'); const { retrieveAndProcessFile } = require('~/server/services/Files/process'); const { processRequiredActions } = require('~/server/services/ToolService'); const { createOnProgress, sendMessage, sleep } = require('~/server/utils'); const { processMessages } = require('~/server/services/Threads'); -const { getLogStores } = require('~/cache'); const { logger } = require('~/config'); /** @@ -611,20 +607,8 @@ class StreamRunManager { const index = this.getStepIndex(stepKey); this.orderedRunSteps.set(index, message_creation); - const messageCache = getLogStores(CacheKeys.MESSAGES); - // Create the Factory Function to stream the message - const { onProgress: progressCallback } = createOnProgress({ - onProgress: throttle( - () => { - messageCache.set(this.finalMessage.messageId, this.getText(), Time.FIVE_MINUTES); - }, - 3000, - { trailing: false }, - ), - }); + const { onProgress: progressCallback } = createOnProgress(); - // This creates a function that attaches all of the parameters - // specified here to each SSE message generated by the TextStream const onProgress = progressCallback({ index, res: this.res, diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 92f8253fc73..9567944d1cc 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -18,7 +18,12 @@ const citationRegex = /\[\^\d+?\^]/g; const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text); const base = { message: true, initial: true }; -const createOnProgress = ({ generation = '', onProgress: _onProgress }) => { +const createOnProgress = ( + { generation = '', onProgress: _onProgress } = { + generation: '', + onProgress: null, + }, +) => { let i = 0; let tokens = addSpaceIfNeeded(generation); diff --git a/client/public/assets/silence.mp3 b/client/public/assets/silence.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..482395f021ca95e262c57c8238a974c5ef909f50 GIT binary patch literal 36494 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 O?5J+!gZ$e+%>M!)vv9-! literal 0 HcmV?d00001 diff --git a/client/src/App.jsx b/client/src/App.jsx index e2b11b261f9..38e568e4225 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -49,5 +49,14 @@ const App = () => { export default () => ( +