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 6cda4471..6ddcaa97 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 89b938b8..8b71dcbc 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 0e518bfe..c15258f9 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 c66d92ae..c2b21cfc 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 fe8b1f1f..f7104d89 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 6534d6b3..b952ab00 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 28fe2c4f..ec618eab 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 706b9db8..53b45d3b 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 047a4134..24a8e38f 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 8408e7a5..036654f8 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 cd4e575e..5547a1fc 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 770cb0f6..54c4aab1 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 f17c2c0b..69cb4bb5 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 8008e553..2f5b9d7b 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 bfb90843..cd718fdf 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 7b6bef03..ac046e68 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 501e252c..e76c0849 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 ae006599..4bab7326 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 92f8253f..9567944d 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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 QTfwn3B{Lu75R&nK03fq)!~g&Q literal 0 HcmV?d00001 diff --git a/client/src/App.jsx b/client/src/App.jsx index e2b11b26..38e568e4 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -49,5 +49,14 @@ const App = () => { export default () => ( +