diff --git a/.depcheckrc b/.depcheckrc new file mode 100644 index 00000000000..37e972d954f --- /dev/null +++ b/.depcheckrc @@ -0,0 +1,2 @@ +ignores: + - "dd-trace" diff --git a/.env.example b/.env.example index a6ff6157cee..6b81247e90f 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,22 @@ CONSOLE_JSON=false DEBUG_LOGGING=true DEBUG_CONSOLE=false +#==================# +# Langfuse Tracing # +#==================# + +# Enables Langfuse tracing for agent endpoints via OpenTelemetry. +# All three keys below are required to enable tracing. +# When enabled, traces include user ID and session (thread) tracking. + +# LANGFUSE_SECRET_KEY=sk-lf-... +# LANGFUSE_PUBLIC_KEY=pk-lf-... +# LANGFUSE_BASE_URL=https://cloud.langfuse.com + +# Optional: Sets the tracing environment label in Langfuse. +# Defaults to NODE_ENV, then 'development' if NODE_ENV is not set. +# LANGFUSE_TRACING_ENVIRONMENT=production + #=============# # Permissions # #=============# @@ -844,3 +860,10 @@ OPENWEATHER_API_KEY= # Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it) # When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration # MCP_SKIP_CODE_CHALLENGE_CHECK=false + +#======================================# +# Product Feedback # +#======================================# + +# Enable the product feedback feature (thumbs-down stores feedback in MongoDB for review) +# PRODUCT_FEEDBACK_ENABLED=true diff --git a/.github/workflows/cbio-build.yml b/.github/workflows/cbio-build.yml new file mode 100644 index 00000000000..37100b6edf0 --- /dev/null +++ b/.github/workflows/cbio-build.yml @@ -0,0 +1,53 @@ +name: Build and Push cBioPortal LibreChat Image + +on: + push: + branches: + - '**' + workflow_dispatch: + inputs: + tag: + description: 'Image tag (e.g. v0.8.3-rc1-custom-v6). Defaults to branch name.' + type: string + required: false + +jobs: + build: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set image tag + id: tag + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + else + # Use branch name, replacing / with - + BRANCH="${GITHUB_REF_NAME//\//-}" + echo "tag=${BRANCH}" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Prepare environment + run: cp .env.example .env + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: cbioportal/librechat:${{ steps.tag.outputs.tag }} + platforms: linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml index f67c1d23be9..01a97bc9b75 100644 --- a/.github/workflows/unused-packages.yml +++ b/.github/workflows/unused-packages.yml @@ -209,6 +209,9 @@ jobs: UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") # Exclude dependencies used in scripts, code, and workspace packages UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "") + # dd-trace is loaded via NODE_OPTIONS=--require dd-trace/init at + # runtime — depcheck can't see that. + UNUSED=$(echo "$UNUSED" | grep -v "^dd-trace$" || echo "") echo "ROOT_UNUSED<> $GITHUB_ENV echo "$UNUSED" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV diff --git a/Dockerfile.multi b/Dockerfile.multi index 5a610725d52..22e34aad6bb 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -81,6 +81,10 @@ COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist COPY --from=client-build /app/client/dist ./client/dist +# Create runtime directories with correct permissions +RUN mkdir -p /app/api/logs /app/uploads /app/client/public/images && \ + chown -R node:node /app +USER node WORKDIR /app/api EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index a2dfaf99070..44224af3406 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -334,7 +334,7 @@ class BaseClient { } async handleTokenCountMap(tokenCountMap) { - if (this.clientName === EModelEndpoint.agents) { + if (this.clientName === EModelEndpoint.agents && !this.shouldSummarize) { return; } if (this.currentMessages.length === 0) { @@ -506,7 +506,7 @@ class BaseClient { shouldSummarize && diff === 1 && firstMessage?.summary && - this.previous_summary.messageId === firstMessage.messageId; + this.previous_summary?.messageId === firstMessage.messageId; if (diff > 0) { payload = formattedMessages.slice(diff); @@ -537,16 +537,36 @@ class BaseClient { } if (usePrevSummary) { - summaryMessage = { role: 'system', content: firstMessage.summary }; + const summaryRole = this.clientName === EModelEndpoint.agents ? 'user' : 'system'; + const summaryContent = this.clientName === EModelEndpoint.agents + ? `[Previous conversation summary]\n${firstMessage.summary}` + : firstMessage.summary; + summaryMessage = { role: summaryRole, content: summaryContent }; summaryTokenCount = firstMessage.summaryTokenCount; - payload.unshift(summaryMessage); + if (this.clientName === EModelEndpoint.agents) { + payload.unshift( + summaryMessage, + { role: 'assistant', content: 'Understood, I have the context from our previous conversation.' }, + ); + } else { + payload.unshift(summaryMessage); + } remainingContextTokens -= summaryTokenCount; } else if (shouldSummarize && messagesToRefine.length > 0) { ({ summaryMessage, summaryTokenCount } = await this.summarizeMessages({ messagesToRefine, remainingContextTokens, })); - summaryMessage && payload.unshift(summaryMessage); + if (summaryMessage) { + if (this.clientName === EModelEndpoint.agents) { + payload.unshift( + summaryMessage, + { role: 'assistant', content: 'Understood, I have the context from our previous conversation.' }, + ); + } else { + payload.unshift(summaryMessage); + } + } remainingContextTokens -= summaryTokenCount; } diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 65c88ce83fc..b0c606f7957 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -410,7 +410,7 @@ const loadTools = async ({ /** MCP server tools are initialized sequentially by server */ let index = -1; const failedMCPServers = new Set(); - const safeUser = createSafeUser(options.req?.user); + const safeUser = createSafeUser(options.req?.user, user); for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) { index++; /** @type {LCAvailableTools} */ diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index 1adda45c35e..a81509491f9 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -28,11 +28,25 @@ jest.mock('~/server/services/Config', () => ({ }, }, }), + getMCPServerTools: jest.fn(), +})); + +jest.mock('~/config', () => ({ + ...jest.requireActual('~/config'), + getMCPServersRegistry: jest.fn(), +})); + +jest.mock('~/server/services/MCP', () => ({ + ...jest.requireActual('~/server/services/MCP'), + createMCPTools: jest.fn(), })); const { Calculator } = require('@librechat/agents'); +const { Constants } = require('librechat-data-provider'); const { User } = require('~/db/models'); +const { getMCPServersRegistry } = require('~/config'); +const { createMCPTools } = require('~/server/services/MCP'); const PluginService = require('~/server/services/PluginService'); const { validateTools, loadTools, loadToolWithAuth } = require('./handleTools'); const { StructuredSD, availableTools, DALLE3 } = require('../'); @@ -283,4 +297,54 @@ describe('Tool Handlers', () => { delete process.env.SD_WEBUI_URL; }); }); + + describe('loadTools MCP user id propagation', () => { + // Regression guard: MCP tool-call requests shipped the literal "{{LIBRECHAT_USER_ID}}" + // placeholder in x-user-id headers because `loadTools` built the MCP `user` object from + // `options.req?.user` alone. When `req.user` is a plain object deserialized from the + // passport session (no `id` virtual, no `_id`), that object resolves to no usable id even + // though `loadTools` is already given the caller's resolved id via its own `user` param + // (see ToolService.js: `loadTools({ user: req.user.id, ... })`). Assert that id makes it + // into the object handed to `createMCPTools`. + const mcpServerName = 'test-mcp-server'; + const mcpAllToolName = `${Constants.mcp_all}${Constants.mcp_delimiter}${mcpServerName}`; + + beforeEach(() => { + getMCPServersRegistry.mockReturnValue({ + getServerConfig: jest.fn().mockResolvedValue({ startup: true }), + }); + createMCPTools.mockResolvedValue([]); + }); + + it('falls back to the loadTools `user` param when req.user has neither id nor _id', async () => { + const userId = fakeUser._id.toString(); + const sessionUser = { email: 'fakeuser@example.com', provider: 'local' }; + + await loadTools({ + user: userId, + tools: [mcpAllToolName], + options: { req: { user: sessionUser } }, + }); + + expect(createMCPTools).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ id: userId }), + }), + ); + }); + + it('prefers a resolvable req.user id over the loadTools `user` param', async () => { + await loadTools({ + user: fakeUser._id.toString(), + tools: [mcpAllToolName], + options: { req: { user: { id: 'explicit-req-user-id' } } }, + }); + + expect(createMCPTools).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ id: 'explicit-req-user-id' }), + }), + ); + }); + }); }); diff --git a/api/models/Agent.js b/api/models/Agent.js index 663285183a4..a91c615c12a 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -717,6 +717,7 @@ const getListAgentsByAccess = async ({ category: 1, support_contact: 1, is_promoted: 1, + conversation_starters: 1, }).sort({ updatedAt: -1, _id: 1 }); // Only apply limit if pagination is requested diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 49240a6b3b7..8119cde3b6c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -48,6 +48,9 @@ const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { createContextHandlers } = require('~/app/clients/prompts'); const { getConvoFiles } = require('~/models/Conversation'); const BaseClient = require('~/app/clients/BaseClient'); +const { SUMMARY_PROMPT, CUT_OFF_PROMPT } = require('~/app/clients/prompts/summaryPrompts'); +const { ChatOpenAI } = require('@langchain/openai'); +const { ChatAnthropic } = require('@langchain/anthropic'); const { getRoleByName } = require('~/models/Role'); const { loadAgent } = require('~/models/Agent'); const { getMCPManager } = require('~/config'); @@ -61,7 +64,13 @@ class AgentClient extends BaseClient { this.clientName = EModelEndpoint.agents; /** @type {'discard' | 'summarize'} */ - this.contextStrategy = 'discard'; + this.contextStrategy = options.contextStrategy ?? 'discard'; + + /** @type {boolean} */ + this.shouldSummarize = this.contextStrategy === 'summarize'; + + /** @type {string | null} */ + this.summaryModel = options.summaryModel ?? null; /** @deprecated @type {true} - Is a Chat Completion Request */ this.isChatCompletion = true; @@ -75,6 +84,8 @@ class AgentClient extends BaseClient { collectedUsage, artifactPromises, maxContextTokens, + contextStrategy: _contextStrategy, + summaryModel: _summaryModel, ...clientOptions } = options; @@ -519,7 +530,7 @@ class AgentClient extends BaseClient { getFormattedMemories: db.getFormattedMemories, }, res: this.options.res, - user: createSafeUser(this.options.req.user), + user: createSafeUser(this.options.req.user, this.user), }); this.processMemory = processMemory; @@ -779,14 +790,16 @@ class AgentClient extends BaseClient { configurable: { thread_id: this.conversationId, last_agent_index: this.agentConfigs?.size ?? 0, - user_id: this.user ?? this.options.req.user?.id, + user_id: this.options.req.user?.email ?? this.user ?? this.options.req.user?.id, + agent_id: this.options.agent.id, + agent_name: this.options.agent.name, hide_sequential_outputs: this.options.agent.hide_sequential_outputs, requestBody: { messageId: this.responseMessageId, conversationId: this.conversationId, parentMessageId: this.parentMessageId, }, - user: createSafeUser(this.options.req.user), + user: createSafeUser(this.options.req.user, this.user), }, recursionLimit: agentsEConfig?.recursionLimit ?? 50, signal: abortController.signal, @@ -861,7 +874,7 @@ class AgentClient extends BaseClient { signal: abortController.signal, customHandlers: this.options.eventHandlers, requestBody: config.configurable.requestBody, - user: createSafeUser(this.options.req?.user), + user: createSafeUser(this.options.req?.user, this.user), tokenCounter: createTokenCounter(this.getEncoding()), }); @@ -1088,7 +1101,7 @@ class AgentClient extends BaseClient { if (clientOptions?.configuration?.defaultHeaders != null) { clientOptions.configuration.defaultHeaders = resolveHeaders({ headers: clientOptions.configuration.defaultHeaders, - user: createSafeUser(this.options.req?.user), + user: createSafeUser(this.options.req?.user, this.user), body: { messageId: this.responseMessageId, conversationId: this.conversationId, @@ -1108,6 +1121,10 @@ class AgentClient extends BaseClient { titlePromptTemplate: endpointConfig?.titlePromptTemplate, chainOptions: { signal: abortController.signal, + configurable: { + user_id: this.options.req.user?.email ?? this.user ?? this.options.req.user?.id, + thread_id: this.conversationId, + }, callbacks: [ { handleLLMEnd, @@ -1222,6 +1239,119 @@ class AgentClient extends BaseClient { return 'o200k_base'; } + /** + * Concatenates messages into a single string for summarization. + * Overrides BaseClient to handle agent content arrays (tool calls, think blocks, etc.). + * @param {Array<{name?: string, role?: string, content: string | Array}>} messages + * @returns {string} + */ + concatenateMessages(messages) { + return messages.reduce((acc, message) => { + const nameOrRole = message.name ?? message.role; + let content; + if (typeof message.content === 'string') { + content = message.content; + } else if (Array.isArray(message.content)) { + content = message.content + .map((part) => { + if (part.type === ContentTypes.TEXT) { + return part[ContentTypes.TEXT] ?? part.text ?? ''; + } + if (part.type === ContentTypes.TOOL_CALL) { + const name = part.name ?? part[ContentTypes.TOOL_CALL]?.name ?? 'tool'; + const output = part.output ?? part[ContentTypes.TOOL_CALL]?.output ?? ''; + return `[Tool: ${name}] ${output}`; + } + return ''; + }) + .filter(Boolean) + .join('\n'); + } else { + content = String(message.content ?? ''); + } + return acc + `${nameOrRole}:\n${content}\n\n`; + }, ''); + } + + /** + * Generates a summary of pruned messages using an LLM call. + * Called by BaseClient.handleContextStrategy() when shouldSummarize is true + * and messages have been pruned from the context window. + * + * @param {Object} params + * @param {TMessage[]} params.messagesToRefine - Messages that were pruned from context + * @param {number} params.remainingContextTokens - Remaining tokens available in context + * @returns {Promise<{summaryMessage?: {role: string, content: string}, summaryTokenCount?: number}>} + */ + async summarizeMessages({ messagesToRefine, remainingContextTokens }) { + try { + const newLines = this.concatenateMessages(messagesToRefine); + if (!newLines.trim()) { + return {}; + } + + let prompt; + if (this.previous_summary?.content) { + prompt = await SUMMARY_PROMPT.format({ + summary: this.previous_summary.content, + new_lines: newLines, + }); + } else { + prompt = await CUT_OFF_PROMPT.format({ + new_lines: newLines, + }); + } + + const model = this.summaryModel ?? this.options.agent.model_parameters.model; + const provider = this.options.agent.provider; + const maxTokens = Math.min(1024, Math.floor(remainingContextTokens * 0.5)); + + let llm; + if (provider === EModelEndpoint.anthropic) { + llm = new ChatAnthropic({ + model, + temperature: 0.2, + streaming: false, + maxTokens, + }); + } else { + llm = new ChatOpenAI({ + model, + temperature: 0.2, + streaming: false, + maxTokens, + }); + } + + const response = await llm.invoke(prompt); + const summaryText = + typeof response.content === 'string' + ? response.content + : response.content?.map?.((p) => p.text ?? '').join('') ?? ''; + + if (!summaryText) { + logger.warn('[AgentClient] summarizeMessages returned empty summary'); + return {}; + } + + const summaryTokenCount = this.getTokenCount(summaryText); + + logger.debug('[AgentClient] Generated context summary', { + prunedMessages: messagesToRefine.length, + summaryTokenCount, + hasPreviousSummary: !!this.previous_summary, + }); + + return { + summaryMessage: { role: 'user', content: `[Previous conversation summary]\n${summaryText}` }, + summaryTokenCount, + }; + } catch (error) { + logger.error('[AgentClient] Failed to summarize messages, falling back to discard', error); + return {}; + } + } + /** * Returns the token count of a given text. It also checks and resets the tokenizers if necessary. * @param {string} text - The text to get the token count for. diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index b334580eb12..526597391c6 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -470,7 +470,7 @@ const OpenAIChatCompletionController = async (req, res) => { configurable: { thread_id: conversationId, user_id: userId, - user: createSafeUser(req.user), + user: createSafeUser(req.user, userId), ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index afdb96be9fa..774d4904a6f 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -485,7 +485,7 @@ const createResponse = async (req, res) => { configurable: { thread_id: conversationId, user_id: userId, - user: createSafeUser(req.user), + user: createSafeUser(req.user, userId), ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, @@ -629,7 +629,7 @@ const createResponse = async (req, res) => { configurable: { thread_id: conversationId, user_id: userId, - user: createSafeUser(req.user), + user: createSafeUser(req.user, userId), ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, diff --git a/api/server/controllers/agents/summarization.test.js b/api/server/controllers/agents/summarization.test.js new file mode 100644 index 00000000000..f6549439d33 --- /dev/null +++ b/api/server/controllers/agents/summarization.test.js @@ -0,0 +1,397 @@ +const { ContentTypes, EModelEndpoint } = require('librechat-data-provider'); + +// Mock LLM classes before requiring the client +const mockInvoke = jest.fn(); +const MockLLMConstructor = jest.fn().mockImplementation(() => ({ + invoke: mockInvoke, +})); + +jest.mock('@langchain/openai', () => ({ + ...jest.requireActual('@langchain/openai'), + ChatOpenAI: MockLLMConstructor, +})); +jest.mock('@langchain/anthropic', () => ({ + ...jest.requireActual('@langchain/anthropic'), + ChatAnthropic: MockLLMConstructor, +})); + +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + checkAccess: jest.fn(), + initializeAgent: jest.fn(), + createMemoryProcessor: jest.fn(), +})); + +jest.mock('~/models/Agent', () => ({ loadAgent: jest.fn() })); +jest.mock('~/models/Role', () => ({ getRoleByName: jest.fn() })); +jest.mock('~/config', () => ({ + getMCPManager: jest.fn(() => ({ + formatInstructionsForContext: jest.fn(), + })), +})); + +const AgentClient = require('./client'); + +describe('AgentClient - Context Summarization', () => { + let client; + let mockAgent; + let mockReq; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAgent = { + id: 'agent-123', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + model_parameters: { model: 'gpt-4' }, + }; + + mockReq = { + user: { id: 'user-123' }, + body: { endpoint: EModelEndpoint.openAI }, + config: {}, + }; + }); + + describe('constructor', () => { + it('should default contextStrategy to "discard"', () => { + client = new AgentClient({ req: mockReq, res: {}, agent: mockAgent }); + expect(client.contextStrategy).toBe('discard'); + expect(client.shouldSummarize).toBe(false); + expect(client.summaryModel).toBeNull(); + }); + + it('should set contextStrategy to "summarize" when configured', () => { + client = new AgentClient({ + req: mockReq, + res: {}, + agent: mockAgent, + contextStrategy: 'summarize', + }); + expect(client.contextStrategy).toBe('summarize'); + expect(client.shouldSummarize).toBe(true); + }); + + it('should set summaryModel when configured', () => { + client = new AgentClient({ + req: mockReq, + res: {}, + agent: mockAgent, + contextStrategy: 'summarize', + summaryModel: 'gpt-4.1-mini', + }); + expect(client.summaryModel).toBe('gpt-4.1-mini'); + }); + + it('should not leak contextStrategy or summaryModel into this.options', () => { + client = new AgentClient({ + req: mockReq, + res: {}, + agent: mockAgent, + contextStrategy: 'summarize', + summaryModel: 'gpt-4.1-mini', + }); + expect(client.options.contextStrategy).toBeUndefined(); + expect(client.options.summaryModel).toBeUndefined(); + }); + }); + + describe('concatenateMessages', () => { + beforeEach(() => { + client = new AgentClient({ req: mockReq, res: {}, agent: mockAgent }); + }); + + it('should concatenate simple string content messages', () => { + const messages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + ]; + const result = client.concatenateMessages(messages); + expect(result).toBe('user:\nHello\n\nassistant:\nHi there\n\n'); + }); + + it('should use name over role when available', () => { + const messages = [{ name: 'Alice', role: 'user', content: 'Hello' }]; + const result = client.concatenateMessages(messages); + expect(result).toBe('Alice:\nHello\n\n'); + }); + + it('should handle content arrays with text parts', () => { + const messages = [ + { + role: 'assistant', + content: [ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'First part' }, + { type: ContentTypes.TEXT, text: 'Second part' }, + ], + }, + ]; + const result = client.concatenateMessages(messages); + expect(result).toContain('First part'); + expect(result).toContain('Second part'); + }); + + it('should handle content arrays with tool call parts', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: ContentTypes.TOOL_CALL, + name: 'search', + output: 'Found 5 results', + }, + ], + }, + ]; + const result = client.concatenateMessages(messages); + expect(result).toContain('[Tool: search]'); + expect(result).toContain('Found 5 results'); + }); + + it('should handle mixed content arrays', () => { + const messages = [ + { + role: 'assistant', + content: [ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Let me search for that.' }, + { type: ContentTypes.TOOL_CALL, name: 'web_search', output: 'Results found' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here are the results.' }, + ], + }, + ]; + const result = client.concatenateMessages(messages); + expect(result).toContain('Let me search for that.'); + expect(result).toContain('[Tool: web_search] Results found'); + expect(result).toContain('Here are the results.'); + }); + + it('should skip unknown content types', () => { + const messages = [ + { + role: 'assistant', + content: [ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Visible text' }, + { type: 'image_url', url: 'http://example.com/img.png' }, + ], + }, + ]; + const result = client.concatenateMessages(messages); + expect(result).toContain('Visible text'); + expect(result).not.toContain('example.com'); + }); + + it('should handle null/undefined content gracefully', () => { + const messages = [{ role: 'user', content: null }]; + const result = client.concatenateMessages(messages); + expect(result).toBe('user:\n\n\n'); + }); + }); + + describe('summarizeMessages', () => { + beforeEach(() => { + client = new AgentClient({ + req: mockReq, + res: {}, + agent: mockAgent, + contextStrategy: 'summarize', + }); + }); + + it('should return a summary message with role "user" and prefix', async () => { + mockInvoke.mockResolvedValue({ + content: 'The user asked about AI and the assistant explained its benefits.', + }); + + const result = await client.summarizeMessages({ + messagesToRefine: [ + { role: 'user', content: 'What is AI?', tokenCount: 10 }, + { role: 'assistant', content: 'AI is artificial intelligence.', tokenCount: 15 }, + ], + remainingContextTokens: 500, + }); + + expect(result.summaryMessage).toBeDefined(); + expect(result.summaryMessage.role).toBe('user'); + expect(result.summaryMessage.content).toBe( + '[Previous conversation summary]\nThe user asked about AI and the assistant explained its benefits.', + ); + expect(result.summaryTokenCount).toBeGreaterThan(0); + expect(typeof result.summaryTokenCount).toBe('number'); + }); + + it('should use SUMMARY_PROMPT when previous_summary exists', async () => { + client.previous_summary = { + messageId: 'msg-1', + content: 'Previous conversation summary about AI.', + }; + + mockInvoke.mockResolvedValue({ + content: 'Updated summary including new discussion points.', + }); + + const result = await client.summarizeMessages({ + messagesToRefine: [ + { role: 'user', content: 'Tell me more about machine learning', tokenCount: 12 }, + { role: 'assistant', content: 'Machine learning is a subset of AI.', tokenCount: 15 }, + ], + remainingContextTokens: 500, + }); + + expect(result.summaryMessage.content).toContain( + 'Updated summary including new discussion points.', + ); + + // Verify the prompt included the previous summary + const invokeArg = mockInvoke.mock.calls[0][0]; + expect(invokeArg).toContain('Previous conversation summary about AI.'); + expect(invokeArg).toContain('Tell me more about machine learning'); + }); + + it('should use ChatOpenAI for OpenAI provider', async () => { + mockInvoke.mockResolvedValue({ content: 'Summary' }); + + await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 500, + }); + + expect(MockLLMConstructor).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4' }), + ); + }); + + it('should use ChatAnthropic for Anthropic provider', async () => { + const anthropicAgent = { + ...mockAgent, + provider: EModelEndpoint.anthropic, + model_parameters: { model: 'claude-sonnet-4-20250514' }, + }; + client = new AgentClient({ + req: mockReq, + res: {}, + agent: anthropicAgent, + contextStrategy: 'summarize', + }); + mockInvoke.mockResolvedValue({ content: 'Summary' }); + + await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 500, + }); + + expect(MockLLMConstructor).toHaveBeenCalledWith( + expect.objectContaining({ model: 'claude-sonnet-4-20250514' }), + ); + }); + + it('should use summaryModel override when configured', async () => { + client.summaryModel = 'gpt-4.1-mini'; + mockInvoke.mockResolvedValue({ content: 'Summary' }); + + await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 500, + }); + + expect(MockLLMConstructor).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4.1-mini' }), + ); + }); + + it('should cap maxTokens at 1024 or 50% of remaining context', async () => { + mockInvoke.mockResolvedValue({ content: 'Summary' }); + + await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 200, + }); + + expect(MockLLMConstructor).toHaveBeenCalledWith( + expect.objectContaining({ maxTokens: 100 }), // 50% of 200 + ); + }); + + it('should cap maxTokens at 1024 for large remaining context', async () => { + mockInvoke.mockResolvedValue({ content: 'Summary' }); + + await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 10000, + }); + + expect(MockLLMConstructor).toHaveBeenCalledWith( + expect.objectContaining({ maxTokens: 1024 }), + ); + }); + + it('should return empty object when messagesToRefine produces empty text', async () => { + const result = await client.summarizeMessages({ + messagesToRefine: [], + remainingContextTokens: 500, + }); + + expect(result).toEqual({}); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('should return empty object when LLM returns empty content', async () => { + mockInvoke.mockResolvedValue({ content: '' }); + + const result = await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 500, + }); + + expect(result).toEqual({}); + }); + + it('should gracefully fall back to empty object on LLM error', async () => { + mockInvoke.mockRejectedValue(new Error('API rate limit exceeded')); + + const result = await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 500, + }); + + expect(result).toEqual({}); + }); + + it('should handle content array responses from the LLM', async () => { + mockInvoke.mockResolvedValue({ + content: [{ text: 'Part 1 ' }, { text: 'Part 2' }], + }); + + const result = await client.summarizeMessages({ + messagesToRefine: [{ role: 'user', content: 'Hello', tokenCount: 5 }], + remainingContextTokens: 500, + }); + + expect(result.summaryMessage.content).toContain('Part 1 Part 2'); + }); + + it('should handle content arrays in messagesToRefine', async () => { + mockInvoke.mockResolvedValue({ content: 'Summary with tool context' }); + + const result = await client.summarizeMessages({ + messagesToRefine: [ + { + role: 'assistant', + content: [ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'I searched for that.' }, + { type: ContentTypes.TOOL_CALL, name: 'search', output: 'Found results' }, + ], + tokenCount: 20, + }, + ], + remainingContextTokens: 500, + }); + + expect(result.summaryMessage.content).toContain('Summary with tool context'); + const invokeArg = mockInvoke.mock.calls[0][0]; + expect(invokeArg).toContain('I searched for that.'); + expect(invokeArg).toContain('[Tool: search] Found results'); + }); + }); +}); diff --git a/api/server/index.js b/api/server/index.js index 193eb423ad9..3d0520c0f26 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -161,6 +161,7 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/api/feedback/issues', routes.feedbackIssues); app.use(ErrorController); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a2dc5b79d27..327de31bdc0 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -112,6 +112,10 @@ router.get('/', async function (req, res) { conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES ? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10) : 0, + productFeedbackEnabled: isEnabled(process.env.PRODUCT_FEEDBACK_ENABLED), + powerUserEmails: process.env.POWER_USER_EMAILS + ? process.env.POWER_USER_EMAILS.split(',').map((e) => e.trim()).filter(Boolean) + : undefined, }; const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); diff --git a/api/server/routes/feedbackIssues.js b/api/server/routes/feedbackIssues.js new file mode 100644 index 00000000000..4806b535c38 --- /dev/null +++ b/api/server/routes/feedbackIssues.js @@ -0,0 +1,44 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const { logger } = require('@librechat/data-schemas'); +const { requireJwtAuth } = require('~/server/middleware'); +const { ProductFeedback } = require('~/db/models'); + +const router = express.Router(); +router.use(requireJwtAuth); + +router.post('/', async (req, res) => { + const { feedback_reason, feedback_title, feedback_details, feedback_suggested_fix, conversation, metadata, contact } = req.body; + + const request_id = req.body.request_id || uuidv4(); + + try { + const record = await ProductFeedback.create({ + request_id, + user: req.user.id, + username: req.user.username || req.user.name || 'unknown', + feedback_reason, + feedback_title, + feedback_details, + feedback_suggested_fix, + conversation, + metadata, + contact, + }); + + logger.info('[feedbackIssues] Feedback saved:', { request_id, id: record._id }); + + return res.json({ + id: record._id.toString(), + request_id, + }); + } catch (error) { + logger.error('[feedbackIssues] Failed to save feedback:', error); + return res.status(500).json({ + error: 'Failed to save feedback', + message: error.message, + }); + } +}); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 6a48919db33..e27d1cabc3e 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -25,6 +25,7 @@ const tags = require('./tags'); const auth = require('./auth'); const keys = require('./keys'); const user = require('./user'); +const feedbackIssues = require('./feedbackIssues'); const mcp = require('./mcp'); module.exports = { @@ -55,5 +56,6 @@ module.exports = { assistants, categories, staticRoute, + feedbackIssues, accessPermissions, }; diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index c208e9c4067..614736e6ebe 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -377,6 +377,99 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) = } }); +/** + * Send feedback as a score to Langfuse (fire-and-forget). + * Looks up the trace by messageId in metadata, then creates a score. + */ +async function sendFeedbackToLangfuse({ messageId, conversationId, feedback }) { + const baseUrl = process.env.LANGFUSE_BASE_URL; + const publicKey = process.env.LANGFUSE_PUBLIC_KEY; + const secretKey = process.env.LANGFUSE_SECRET_KEY; + if (!baseUrl || !publicKey || !secretKey) { + return; + } + + try { + const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64'); + const headers = { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }; + + // Find the trace for this messageId + const searchUrl = `${baseUrl}/api/public/traces?limit=1&sessionId=${conversationId}`; + const searchRes = await fetch(searchUrl, { headers }); + if (!searchRes.ok) { + return; + } + const searchData = await searchRes.json(); + // Find trace whose metadata.messageId matches + const traces = searchData.data || []; + let traceId; + for (const trace of traces) { + if (trace.metadata?.messageId === messageId) { + traceId = trace.id; + break; + } + } + + // If sessionId search didn't match metadata, try broader search + if (!traceId) { + const broadUrl = `${baseUrl}/api/public/traces?limit=10&sessionId=${conversationId}`; + const broadRes = await fetch(broadUrl, { headers }); + if (broadRes.ok) { + const broadData = await broadRes.json(); + for (const trace of broadData.data || []) { + if (trace.metadata?.messageId === messageId) { + traceId = trace.id; + break; + } + } + } + } + + if (!traceId) { + logger.debug(`[Langfuse] No trace found for messageId=${messageId}`); + return; + } + + let comment = ''; + let feedbackText = feedback.text || ''; + // Product feedback stores JSON in text — extract human-readable parts + if (feedbackText.startsWith('{')) { + try { + const parsed = JSON.parse(feedbackText); + const parts = []; + if (parsed.feedback_reason) { + parts.push(`reason: ${parsed.feedback_reason}`); + } + if (parsed.feedback_details) { + parts.push(parsed.feedback_details); + } + if (parsed.feedback_suggested_fix) { + parts.push(`expected: ${parsed.feedback_suggested_fix}`); + } + feedbackText = parts.join(' | '); + } catch { + // keep original text + } + } + const tag = feedback.tag?.key || feedback.tag || ''; + comment = [tag, feedbackText].filter(Boolean).join(': '); + + await fetch(`${baseUrl}/api/public/scores`, { + method: 'POST', + headers, + body: JSON.stringify({ + traceId, + name: 'user_feedback', + dataType: 'BOOLEAN', + value: feedback.rating === 'thumbsUp' ? 1 : 0, + ...(comment ? { comment } : {}), + }), + }); + } catch (err) { + logger.debug('[Langfuse] Failed to send feedback score:', err.message); + } +} + router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => { try { const { conversationId, messageId } = req.params; @@ -396,6 +489,11 @@ router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (re conversationId, feedback: updatedMessage.feedback, }); + + // Fire-and-forget: send feedback to Langfuse + if (feedback?.rating) { + sendFeedbackToLangfuse({ messageId, conversationId, feedback }).catch(() => {}); + } } catch (error) { logger.error('Error updating message feedback:', error); res.status(500).json({ error: 'Failed to update feedback' }); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 0888f23cd5b..1fbac28dbb8 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -363,6 +363,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { primaryConfig.edges = edges; let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint]; + const agentsConfig = appConfig.endpoints?.[EModelEndpoint.agents]; if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { try { endpointConfig = getCustomEndpointConfig({ @@ -403,6 +404,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { endpointType: endpointOption.endpointType, resendFiles: primaryConfig.resendFiles ?? true, maxContextTokens: primaryConfig.maxContextTokens, + contextStrategy: agentsConfig?.contextStrategy ?? endpointConfig?.contextStrategy, + summaryModel: agentsConfig?.summaryModel ?? endpointConfig?.summaryModel, endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, }); diff --git a/client/index.html b/client/index.html index c94c3981b41..34983f88a24 100644 --- a/client/index.html +++ b/client/index.html @@ -7,8 +7,8 @@ - - LibreChat + + cBioChat diff --git a/client/public/assets/apple-touch-icon-180x180.png b/client/public/assets/apple-touch-icon-180x180.png index 57c4637c934..5f455cbc9cf 100644 Binary files a/client/public/assets/apple-touch-icon-180x180.png and b/client/public/assets/apple-touch-icon-180x180.png differ diff --git a/client/public/assets/favicon-16x16.png b/client/public/assets/favicon-16x16.png index 03975d8ec0b..b72e126529d 100644 Binary files a/client/public/assets/favicon-16x16.png and b/client/public/assets/favicon-16x16.png differ diff --git a/client/public/assets/favicon-32x32.png b/client/public/assets/favicon-32x32.png index df89fb33b01..c0b4fca8a8b 100644 Binary files a/client/public/assets/favicon-32x32.png and b/client/public/assets/favicon-32x32.png differ diff --git a/client/public/assets/icon-192x192.png b/client/public/assets/icon-192x192.png index b8dfe0eae57..e528587cca4 100644 Binary files a/client/public/assets/icon-192x192.png and b/client/public/assets/icon-192x192.png differ diff --git a/client/public/assets/maskable-icon.png b/client/public/assets/maskable-icon.png index b48524b8672..1d0f3cc34e6 100644 Binary files a/client/public/assets/maskable-icon.png and b/client/public/assets/maskable-icon.png differ diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 66dec68f64e..fd5a5b638fa 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -9,6 +9,7 @@ import type { ChatFormValues } from '~/common'; import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers'; import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks'; import ConversationStarters from './Input/ConversationStarters'; +import LimitBadge from './Input/LimitBadge'; import { useGetMessagesByConvoId } from '~/data-provider'; import MessagesView from './Messages/MessagesView'; import Presentation from './Presentation'; @@ -88,7 +89,7 @@ function ChatView({ index = 0 }: { index?: number }) { className={cn( 'flex flex-col', isLandingPage - ? 'flex-1 items-center justify-end sm:justify-center' + ? 'min-h-0 flex-1 items-center justify-start overflow-y-auto overscroll-contain pb-4 pt-4 sm:pt-6' : 'h-full overflow-y-auto', )} > @@ -100,6 +101,7 @@ function ChatView({ index = 0 }: { index?: number }) { )} > + {isLandingPage && } {isLandingPage ? :