Compare commits

...

33 Commits

Author SHA1 Message Date
Dustin Healy
4fb27dbaff fix: format error return for file_search tool in two-tuple as expected 2025-11-17 09:20:32 -08:00
Danny Avila
1b2f1ff09b 🚪 fix: ArtifactsPanel and SidePanel Rendering and Collapsing Behavior (#10537)
* 🚪 fix: ArtifactsPanel and SidePanel Rendering and Collapsing Behavior

* refactor: improve side panel behavior when artifacts panel renders null
2025-11-16 13:55:35 -05:00
Adaptive Garage
0a2f40cc50 🪣 feat: Init Containers and Custom ConfigMaps Support in Helm Chart (#10525) 2025-11-16 12:03:34 -05:00
Theo N. Truong
8c531b921e 🐛 fix: Redis Cluster Bug + 🧪 Enhance Test Coverage (#10518)
*  feat: Implement scanIterator method for Redis cluster client
This resolves the bug where `ServerConfigsCacheRedis#getAll` returns an empty object when a Redis Cluster (instead of a single node server is used)

*  feat: Update cache integration tests for Redis cluster support
2025-11-16 11:58:52 -05:00
Danny Avila
f228f2a91d 📦 chore: Jest & Eslint Package Updates (#10536)
* chore: update js-yaml to v4.1.1

* chore: update eslint to v9.39.1 in package.json and package-lock.json

* chore: update prettier-eslint to v16.4.2 in package.json and package-lock.json

* chore: update @eslint/eslintrc to v3.3.1 in package.json and package-lock.json

* chore: update ts-jest to v29.4.5 in package.json and package-lock.json

* chore: update jest to version 30.2.0 across multiple packages and update related dependencies
2025-11-16 11:55:18 -05:00
github-actions[bot]
59b57623f7 🌍 i18n: Update translation.json with latest translations (#10519)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-16 11:46:16 -05:00
Danny Avila
61c4736125 📜 chore: Update deployed-update.js to use 'docker compose' syntax 2025-11-14 13:53:22 -05:00
Danny Avila
d844754edf 📼 fix: Remove Legacy File Upload for Non-agents (#10517) 2025-11-14 13:17:17 -05:00
Danny Avila
6522789f5b 🤖 feat: GPT-5.1 (#10491) 2025-11-14 12:28:20 -05:00
Marco Beretta
e71c48ec3d 🎨 fix: Correct Read-Only State Logic in Code Editor (#10508)
*  style: Update ThinkingButton container background color for improved visibility

*  style: Refactor Clipboard icon rendering for improved readability

*  style: Simplify readOnly state initialization and update logic in ArtifactCodeEditor

*  style: Update Thinking component background color for improved aesthetics

* Update client/src/components/Chat/Messages/MinimalHoverButtons.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:27:51 -05:00
Maxim
f6868fc851 🔤 fix: Replace Translation Keys with Localized Text (#10486)
Co-authored-by: Max Dutkin <dutkinm@corning.com>
2025-11-13 17:01:24 -05:00
Marco Beretta
c2505d2bc9 🤝 feat: View Artifacts in Shared Conversations (#10477)
* feat: Integrate logger for MessageIcon component

* feat: Enhance artifact sharing functionality with updated path checks and read-only state management

* feat: Refactor Thinking and Reasoning components for improved structure and styling

* feat: Enhance artifact sharing with context value management and responsive layout

* feat: Enhance ShareView with theme and language management features

* feat: Improve ThinkingButton accessibility and styling for better user interaction

* feat: Introduce isArtifactRoute utility for route validation in Artifact components

* feat: Add latest message text extraction in SharedView for improved message display

* feat: Update locale handling in SharedView for dynamic date formatting

* feat: Refactor ArtifactsContext and SharedView for improved context handling and styling adjustments

* feat: Enhance artifact panel size management with local storage integration

* chore: imports

* refactor: move ShareArtifactsContainer out of ShareView

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-13 16:59:46 -05:00
Danny Avila
cabc8afeac 🔧 fix: Await MCP Instructions and Filter Malformed Tool Calls (#10485)
* fix: Await MCP instructions formatting in AgentClient

* fix: don't render or aggregate malformed tool calls

* fix: implement filter for malformed tool call content parts and add tests
2025-11-13 14:17:47 -05:00
github-actions[bot]
aff3cd3667 🌍 i18n: Update translation.json with latest translations (#10481)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-13 10:22:19 -05:00
Dustin Healy
c9ee0f138a 🪨 feat: Add Bedrock Prompt Caching Support (#8271)
* feat: Add Bedrock Cache Control Functionality

- fix: Update Bedrock Cache Control to Require cachePoint as a Separate Content Block

- Modified the addBedrockCacheControl function to ensure cachePoint is added as a separate content block in the content array, rather than as a property of text objects.

- refactor: move addBedrockCacheControl over to packages/api

- ci: add tests for addBedrockCacheControl until full coverage reached

* ci: add test similar to example from the langchain PR

* refactor: move addBedrockCacheControl logic and tests to agents repository

* chore: remove extraneous comment

* chore: update @librechat/agents dependency to version 3.0.12

* chore: update @librechat/agents dependency to version 3.0.13

* chore: update @librechat/agents dependency to version 3.0.14

* chore: update @librechat/agents to v3.0.15

* chore: update default value for prompt cache setting to true

* refactor: set default promptCache to true for claude and nova models

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-13 10:21:50 -05:00
_juliettech
bc561840bb 🌉 feat: Integrate Helicone AI Gateway Provider (#10287)
* feat: integrate Helicone AI gateway provider

- Add Helicone provider support with automatic model fetching
- Implement custom API logic for Helicone model registry endpoint
- Enable access to 75+ models from multiple AI providers through Helicone gateway
- Add Helicone to supported providers list in README
- Include Helicone configuration in example YAML

* docs: add Helicone to supported providers list

* fix comments

* fixed backgroundless helicone icon asset

* removed unecessesary changes

* replace svg helicone image instead of png
2025-11-13 08:45:32 -05:00
Danny Avila
6e19026c48 🔍 feat: DEBUG_MESSAGE_LENGTH Environment Variable (pt. 2) (#10479) 2025-11-13 08:38:38 -05:00
Danny Avila
524fc5bae4 🛡️ feat: Add Model Refusal Error Handling (Anthropic) (#10478)
* feat: Add error handling for model refusal and update translations

* refactor: error handling in AgentClient to improve logging and cleanup process

* refactor: Update error message for response refusal to improve clarity
2025-11-13 08:34:55 -05:00
Danny Avila
3f62ce054f 🔢 fix: Unescape LaTeX Numbers in Artifact Content Edit (#10476) 2025-11-13 08:19:19 -05:00
Danny Avila
b8b1217c34 feat: Artifact Management Enhancements, Version Control, and UI Refinements (#10318)
*  feat: Enhance Artifact Management with Version Control and UI Improvements

 feat: Improve mobile layout and responsiveness in Artifacts component

 feat: Refactor imports and remove unnecessary props in Artifact components

 feat: Enhance Artifacts and SidePanel components with improved mobile responsiveness and layout transitions

feat: Enhance artifact panel animations and improve UI responsiveness

- Updated Thinking component button styles for smoother transitions.
- Implemented dynamic rendering for artifacts panel with animation effects.
- Refactored localization keys for consistency across multiple languages.
- Added new CSS animations for iOS-inspired smooth transitions.
- Improved Tailwind CSS configuration to support enhanced animation effects.

 feat: Add fullWidth and icon support to Radio component for enhanced flexibility

refactor: Remove unused PreviewProps import in ArtifactPreview component

refactor: Improve button class handling and blur effect constants in Artifact components

 feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI

chore: Bump @librechat/client version to 0.3.2

refactor: Update button styles and transition durations for improved UI responsiveness

refactor: revert back localization key

refactor: remove unused scaling and animation properties for cleaner CSS

refactor: remove unused animation properties for cleaner configuration

*  refactor: Simplify className usage in ArtifactTabs, ArtifactsHeader, and SidePanelGroup components

* refactor: Remove cycleArtifact function from useArtifacts hook

*  feat: Implement Chromium resize lag fix with performance optimizations and new ArtifactsPanel component

*  feat: Update Badge component for responsive design and improve tap scaling behavior

* chore: Update react-resizable-panels dependency to version 3.0.6

*  feat: Refactor Artifacts components for improved structure and performance; remove unused files and optimize styles

*  style: Update text color for improved visibility in Artifacts component

*  style: Remove text color class for improved Spinner styling in Artifacts component

* refactor: Split EditorContext into MutationContext and CodeContext to optimize re-renders; update related components to use new hooks

* refactor: Optimize debounced mutation handling in CodeEditor component using refs to maintain current values and reduce re-renders

* fix: Correct endpoint for message artifacts by changing URL segment from 'artifacts' to 'artifact'

* feat: Enhance useEditArtifact mutation with optimistic updates and rollback on error; improve type safety with context management

* fix: proper switch to preview as soon as artifact becomes enclosed

* refactor: Remove optimistic updates from useEditArtifact mutation to prevent errors; simplify onMutate logic

* test: Add comprehensive unit tests for useArtifacts hook to validate artifact handling, tab switching, and state management

* test: Enhance unit tests for useArtifacts hook to cover new conversation transitions and null message handling

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2025-11-12 13:32:47 -05:00
Danny Avila
4186db3ce2 📦 chore: Bump @modelcontextprotocol/sdk to v1.21.0 (#10469) 2025-11-12 09:10:21 -05:00
github-actions[bot]
7670cd9ee5 🌍 i18n: Update translation.json with latest translations (#10458)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-12 08:47:15 -05:00
Danny Avila
dd35f42073 🔒 feat: Idempotency Check for OAuth Flow Completion (#10468)
* 🔒 feat: Implement idempotency check for OAuth flow completion

- Added a check to prevent duplicate token exchanges if the OAuth flow has already been completed.
- Updated the OAuth callback route to redirect appropriately when a completed flow is detected.
- Refactored token storage logic to use original flow state credentials instead of updated ones.
- Enhanced tests to cover the new idempotency behavior and ensure correct handling of OAuth flow states.

* chore: add back scope for logging

* refactor: Add isFlowStale method to FlowStateManager for stale flow detection

- Implemented a new method to check if a flow is stale based on its age and status.
- Updated MCPConnectionFactory to utilize the isFlowStale method for cleaning up stale OAuth flows.
- Enhanced logging to provide more informative messages regarding flow status and age during cleanup.

* test: Add unit tests for isFlowStale method in FlowStateManager

- Implemented comprehensive tests for the isFlowStale method to verify its behavior across various flow statuses (PENDING, COMPLETED, FAILED) and age thresholds.
- Ensured correct handling of edge cases, including flows with missing timestamps and custom stale thresholds.
- Enhanced test coverage to validate the logic for determining flow staleness based on createdAt, completedAt, and failedAt timestamps.
2025-11-12 08:44:45 -05:00
Danny Avila
a49c509ebc 📐 chore: Update extractDefaultParams to return undefined for invalid input 2025-11-11 15:36:07 -05:00
Danny Avila
970a7510bb 🛝 feat: Default Params via Custom Params (#10457) 2025-11-11 15:31:52 -05:00
Danny Avila
2b0fe036a8 🔍 feat: Anthropic/Google Web Search Support via addParams / dropParams (#10456)
* feat: add support for known/add/drop parameters in Anthropic and Google LLM configurations

* ci: add tests for web search support for Anthropic and Google configurations with addParams and dropParams handling
2025-11-11 14:39:12 -05:00
github-actions[bot]
4685a063f5 🌍 i18n: Update translation.json with latest translations (#10448)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-11 14:15:41 -05:00
Danny Avila
b6ba2711f9 Merge commit from fork
- Implemented validation for OpenAPI specifications to ensure the server URL matches the client-provided domain, preventing SSRF attacks.
- Added domain extraction and validation functions to improve security checks.
- Updated relevant services and routes to utilize the new validation logic, ensuring robust handling of client-provided domains against the OpenAPI spec.
- Introduced comprehensive tests to validate the new security features and ensure correct behavior across various scenarios.
2025-11-11 14:14:55 -05:00
Danny Avila
4e4c8d0c0e 📜 feat: Configurable Debug Message Length for Logs (#10447)
- Added DEBUG_MESSAGE_LENGTH constant to allow dynamic adjustment of debug message length based on environment variable.
- Updated logging format to utilize the new constant for truncating debug messages, enhancing flexibility in log output.
2025-11-10 21:40:37 -05:00
Danny Avila
937563f645 🖼️ feat: File Size and MIME Type Filtering at Agent level (#10446)
* refactor: add image file size validation as part of payload build

* feat: implement file size and MIME type filtering in endpoint configuration

* chore: import order
2025-11-10 21:36:48 -05:00
Sean McGrath
b443254151 🔐 fix: persist new MCP oauth tokens properly (#10439)
* fix: re-fetch OAuth flow state after completeOAuthFlow

* test: add tests for MCP OAuth flow state bugs
2025-11-10 19:51:20 -05:00
Danny Avila
2524d33362 📂 refactor: Cleanup File Filtering Logic, Improve Validation (#10414)
* feat: add filterFilesByEndpointConfig to filter disabled file processing by provider

* chore: explicit define of endpointFileConfig for better debugging

* refactor: move `normalizeEndpointName` to data-provider as used app-wide

* chore: remove overrideEndpoint from useFileHandling

* refactor: improve endpoint file config selection

* refactor: update filterFilesByEndpointConfig to accept structured parameters and improve endpoint file config handling

* refactor: replace defaultFileConfig with getEndpointFileConfig for improved file configuration handling across components

* test: add comprehensive unit tests for getEndpointFileConfig to validate endpoint configuration handling

* refactor: streamline agent endpoint assignment and improve file filtering logic

* feat: add error handling for disabled file uploads in endpoint configuration

* refactor: update encodeAndFormat functions to accept structured parameters for provider and endpoint

* refactor: streamline requestFiles handling in initializeAgent function

* fix: getEndpointFileConfig partial config merging scenarios

* refactor: enhance mergeWithDefault function to support document-supported providers with comprehensive MIME types

* refactor: user-configured default file config in getEndpointFileConfig

* fix: prevent file handling when endpoint is disabled and file is dragged to chat

* refactor: move `getEndpointField` to `data-provider` and update usage across components and hooks

* fix: prioritize endpointType based on agent.endpoint in file filtering logic

* fix: prioritize agent.endpoint in file filtering logic and remove unnecessary endpointType defaulting
2025-11-10 19:05:30 -05:00
Danny Avila
06c060b983 🧰 fix: Unprocessed Tool Calls Edge Case (#10440)
* chore: temp. remove @librechat/agents

* 🔧 chore: update @langchain/core to version 0.3.79

* chore: update dependencies for @langchain/core and add back latest @librechat/agents

* chore: update @librechat/agents to version 3.0.11

* fix: enhance error handling for uncaught exceptions due to abort errors

* fix: standardize warning message for uncatchable abort errors

* fix: improve tool call handling in ModelEndHandler for unprocessed edge case

* fix: prevent content type mismatch in message updates and preserve args in final updates

* chore: add debug logging for client disposal in disposeClient function
2025-11-10 17:12:06 -05:00
173 changed files with 11390 additions and 2357 deletions

View File

@@ -61,30 +61,23 @@ jobs:
npm run build:data-schemas
npm run build:api
- name: Run cache integration tests
- name: Run all cache integration tests (Single Redis Node)
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
USE_REDIS_CLUSTER: false
REDIS_URI: redis://127.0.0.1:6379
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
run: npm run test:cache-integration:core
run: npm run test:cache-integration
- name: Run cluster integration tests
- name: Run all cache integration tests (Redis Cluster)
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
REDIS_URI: redis://127.0.0.1:6379
run: npm run test:cache-integration:cluster
- name: Run mcp integration tests
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
REDIS_URI: redis://127.0.0.1:6379
run: npm run test:cache-integration:mcp
USE_REDIS_CLUSTER: true
REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
run: npm run test:cache-integration
- name: Stop Redis Cluster
if: always()

View File

@@ -56,7 +56,7 @@
- [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
- Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
- Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
- OpenRouter, Perplexity, ShuttleAI, Deepseek, Qwen, and more
- OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen, and more
- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
- Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran

View File

@@ -305,11 +305,9 @@ class AnthropicClient extends BaseClient {
}
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
EModelEndpoint.anthropic,
);
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
endpoint: EModelEndpoint.anthropic,
});
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}

View File

@@ -1213,6 +1213,7 @@ class BaseClient {
attachments,
{
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
},
getStrategyFunctions,
@@ -1228,7 +1229,10 @@ class BaseClient {
const videoResult = await encodeAndFormatVideos(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
},
getStrategyFunctions,
);
message.videos =
@@ -1240,7 +1244,10 @@ class BaseClient {
const audioResult = await encodeAndFormatAudios(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
},
getStrategyFunctions,
);
message.audios =

View File

@@ -305,7 +305,9 @@ class GoogleClient extends BaseClient {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
EModelEndpoint.google,
{
endpoint: EModelEndpoint.google,
},
mode,
);
message.image_urls = image_urls.length ? image_urls : undefined;

View File

@@ -354,11 +354,9 @@ class OpenAIClient extends BaseClient {
* @returns {Promise<MongoFile[]>}
*/
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
this.options.endpoint,
);
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
endpoint: this.options.endpoint,
});
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}

View File

@@ -78,11 +78,11 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
return tool(
async ({ query }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
return ['No files to search. Instruct the user to add files for the search.', {}];
}
const jwtToken = generateShortLivedToken(userId);
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
return ['There was an error authenticating the file search request.', {}];
}
/**
@@ -122,7 +122,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
const validResults = results.filter((result) => result !== null);
if (validResults.length === 0) {
return 'No results found or errors occurred while searching the files.';
return ['No results found or errors occurred while searching the files.', {}];
}
const formattedResults = validResults

View File

@@ -5,6 +5,7 @@ const traverse = require('traverse');
const SPLAT_SYMBOL = Symbol.for('splat');
const MESSAGE_SYMBOL = Symbol.for('message');
const CONSOLE_JSON_STRING_LENGTH = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH) || 255;
const DEBUG_MESSAGE_LENGTH = parseInt(process.env.DEBUG_MESSAGE_LENGTH) || 150;
const sensitiveKeys = [
/^(sk-)[^\s]+/, // OpenAI API key pattern
@@ -118,7 +119,7 @@ const debugTraverse = winston.format.printf(({ level, message, timestamp, ...met
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
}
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), 150)}`;
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), DEBUG_MESSAGE_LENGTH)}`;
try {
if (level !== 'debug') {
return msg;

View File

@@ -43,15 +43,15 @@
"@google/generative-ai": "^0.24.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.72",
"@langchain/core": "^0.3.79",
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^3.0.5",
"@librechat/agents": "^3.0.17",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.17.1",
"@modelcontextprotocol/sdk": "^1.21.0",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.12.1",
@@ -76,7 +76,7 @@
"handlebars": "^4.7.7",
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.3.2",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.2.0",
"keyv": "^5.3.2",
@@ -117,7 +117,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"jest": "^29.7.0",
"jest": "^30.2.0",
"mongodb-memory-server": "^10.1.4",
"nodemon": "^3.0.3",
"supertest": "^7.1.0"

View File

@@ -376,6 +376,8 @@ function disposeClient(client) {
client.options = null;
} catch {
// Ignore errors during disposal
} finally {
logger.debug('[disposeClient] Client disposed');
}
}

View File

@@ -1,7 +1,7 @@
const { nanoid } = require('nanoid');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
const {
EnvVar,
Providers,
@@ -27,6 +27,13 @@ class ModelEndHandler {
this.collectedUsage = collectedUsage;
}
finalize(errorMessage) {
if (!errorMessage) {
return;
}
throw new Error(errorMessage);
}
/**
* @param {string} event
* @param {ModelEndData | undefined} data
@@ -40,18 +47,44 @@ class ModelEndHandler {
return;
}
/** @type {string | undefined} */
let errorMessage;
try {
const agentContext = graph.getAgentContext(metadata);
if (
agentContext.provider === Providers.GOOGLE ||
agentContext.clientOptions?.disableStreaming
) {
handleToolCalls(data?.output?.tool_calls, metadata, graph);
const isGoogle = agentContext.provider === Providers.GOOGLE;
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
const info = { ...data.output.additional_kwargs };
errorMessage = JSON.stringify({
type: ErrorTypes.REFUSAL,
info,
});
logger.debug(`[ModelEndHandler] Model refused to respond`, {
...info,
userId: metadata.user_id,
messageId: metadata.run_id,
conversationId: metadata.thread_id,
});
}
const toolCalls = data?.output?.tool_calls;
let hasUnprocessedToolCalls = false;
if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
try {
hasUnprocessedToolCalls = toolCalls.some(
(tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
);
} catch {
hasUnprocessedToolCalls = false;
}
}
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
handleToolCalls(toolCalls, metadata, graph);
}
const usage = data?.output?.usage_metadata;
if (!usage) {
return;
return this.finalize(errorMessage);
}
const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
if (modelName) {
@@ -59,12 +92,11 @@ class ModelEndHandler {
}
this.collectedUsage.push(usage);
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
if (!streamingDisabled) {
return;
return this.finalize(errorMessage);
}
if (!data.output.content) {
return;
return this.finalize(errorMessage);
}
const stepKey = graph.getStepKey(metadata);
const message_id = getMessageId(stepKey, graph) ?? '';
@@ -94,6 +126,7 @@ class ModelEndHandler {
}
} catch (error) {
logger.error('Error handling model end event:', error);
return this.finalize(errorMessage);
}
}
}

View File

@@ -13,6 +13,7 @@ const {
memoryInstructions,
getTransactionsConfig,
createMemoryProcessor,
filterMalformedContentParts,
} = require('@librechat/api');
const {
Callback,
@@ -210,7 +211,10 @@ class AgentClient extends BaseClient {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent.provider,
endpoint: this.options.endpoint,
},
VisionModes.agents,
);
message.image_urls = image_urls.length ? image_urls : undefined;
@@ -341,7 +345,7 @@ class AgentClient extends BaseClient {
if (mcpServers.length > 0) {
try {
const mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers);
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
if (mcpInstructions) {
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
@@ -608,7 +612,7 @@ class AgentClient extends BaseClient {
userMCPAuthMap: opts.userMCPAuthMap,
abortController: opts.abortController,
});
return this.contentParts;
return filterMalformedContentParts(this.contentParts);
}
/**
@@ -761,12 +765,14 @@ class AgentClient extends BaseClient {
let run;
/** @type {Promise<(TAttachment | null)[] | undefined>} */
let memoryPromise;
const appConfig = this.options.req.config;
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
try {
if (!abortController) {
abortController = new AbortController();
}
const appConfig = this.options.req.config;
/** @type {AppConfig['endpoints']['agents']} */
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
@@ -896,31 +902,7 @@ class AgentClient extends BaseClient {
);
});
}
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
await this.recordCollectedUsage({
context: 'message',
balance: balanceConfig,
transactions: transactionsConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
err,
);
}
} catch (err) {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
logger.error(
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
err,
@@ -935,6 +917,24 @@ class AgentClient extends BaseClient {
[ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
});
}
} finally {
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
await this.recordCollectedUsage({
context: 'message',
balance: balanceConfig,
transactions: transactionsConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
err,
);
}
}
}

View File

@@ -14,6 +14,14 @@ jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
}));
// Mock getMCPManager
const mockFormatInstructions = jest.fn();
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
formatInstructionsForContext: mockFormatInstructions,
})),
}));
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
@@ -981,7 +989,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic that handles GPT-5+ models
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1001,7 +1009,7 @@ describe('AgentClient - titleConvo', () => {
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
@@ -1026,7 +1034,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1047,7 +1055,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1060,6 +1068,9 @@ describe('AgentClient - titleConvo', () => {
it('should handle various GPT-5+ model formats', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
@@ -1079,7 +1090,10 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1097,6 +1111,9 @@ describe('AgentClient - titleConvo', () => {
it('should not swap max token param for older models when using useResponsesApi', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
@@ -1116,7 +1133,10 @@ describe('AgentClient - titleConvo', () => {
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
@@ -1149,7 +1169,10 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1168,6 +1191,200 @@ describe('AgentClient - titleConvo', () => {
});
});
describe('buildMessages with MCP server instructions', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock to default behavior
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nTest MCP instructions here',
);
const { DynamicStructuredTool } = require('@langchain/core/tools');
// Create mock MCP tools with the delimiter pattern
const mockMCPTool1 = new DynamicStructuredTool({
name: `tool1${Constants.mcp_delimiter}server1`,
description: 'Test MCP tool 1',
schema: {},
func: async () => 'result',
});
const mockMCPTool2 = new DynamicStructuredTool({
name: `tool2${Constants.mcp_delimiter}server2`,
description: 'Test MCP tool 2',
schema: {},
func: async () => 'result',
});
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Base agent instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [mockMCPTool1, mockMCPTool2],
};
mockReq = {
user: {
id: 'user-123',
},
body: {
endpoint: EModelEndpoint.openAI,
},
config: {},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
endpoint: EModelEndpoint.agents,
};
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.shouldSummarize = false;
client.maxContextTokens = 4096;
});
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nUse these tools carefully',
);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with correct server names
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
// Verify the instructions do NOT contain [object Promise]
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify the instructions DO contain the MCP instructions
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
expect(client.options.agent.instructions).toContain('Use these tools carefully');
// Verify the base instructions are also included
expect(client.options.agent.instructions).toContain('Base instructions');
});
it('should handle MCP instructions with ephemeral agent', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
);
// Set up ephemeral agent with MCP servers
mockReq.body.ephemeralAgent = {
mcp: ['ephemeral-server1', 'ephemeral-server2'],
};
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Test ephemeral',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Ephemeral instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with ephemeral server names
expect(mockFormatInstructions).toHaveBeenCalledWith([
'ephemeral-server1',
'ephemeral-server2',
]);
// Verify no [object Promise] in instructions
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify ephemeral MCP instructions are included
expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
});
it('should handle empty MCP instructions gracefully', async () => {
// Set empty return value for this test
mockFormatInstructions.mockResolvedValue('');
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions only',
additional_instructions: null,
});
// Verify the instructions still work without MCP content
expect(client.options.agent.instructions).toBe('Base instructions only');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
it('should handle MCP instructions error gracefully', async () => {
// Set error return for this test
mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
// Should not throw
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Should still have base instructions without MCP content
expect(client.options.agent.instructions).toContain('Base instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
});
describe('runMemory method', () => {
let client;
let mockReq;

View File

@@ -185,8 +185,8 @@ process.on('uncaughtException', (err) => {
logger.error('There was an uncaught error:', err);
}
if (err.message.includes('abort')) {
logger.warn('There was an uncatchable AbortController error.');
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
logger.warn('There was an uncatchable abort error.');
return;
}

View File

@@ -290,6 +290,7 @@ describe('MCP Routes', () => {
it('should handle OAuth callback successfully', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -382,6 +383,7 @@ describe('MCP Routes', () => {
it('should handle system-level OAuth completion', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -417,6 +419,7 @@ describe('MCP Routes', () => {
it('should handle reconnection failure after OAuth', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -498,6 +501,108 @@ describe('MCP Routes', () => {
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
});
it('should use original flow state credentials when storing tokens', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn(),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const clientInfo = {
client_id: 'client123',
client_secret: 'client_secret',
};
const flowState = {
serverName: 'test-server',
userId: 'test-user-id',
metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
clientInfo: clientInfo,
codeVerifier: 'test-verifier',
status: 'PENDING',
};
const mockTokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// First call checks idempotency (status PENDING = not completed)
// Second call retrieves flow state for processing
mockFlowManager.getFlowState
.mockResolvedValueOnce({ status: 'PENDING' })
.mockResolvedValueOnce(flowState);
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockUserConnection = {
fetchTools: jest.fn().mockResolvedValue([]),
};
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
clearReconnection: jest.fn(),
});
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
});
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
// Verify storeTokens was called with ORIGINAL flow state credentials
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'test-user-id',
serverName: 'test-server',
tokens: mockTokens,
clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
metadata: flowState.metadata,
}),
);
});
it('should prevent duplicate token exchange with idempotency check', async () => {
const mockFlowManager = {
getFlowState: jest.fn(),
};
// Flow is already completed
mockFlowManager.getFlowState.mockResolvedValue({
status: 'COMPLETED',
serverName: 'test-server',
userId: 'test-user-id',
});
MCPOAuthHandler.getFlowState.mockResolvedValue({
status: 'COMPLETED',
serverName: 'test-server',
userId: 'test-user-id',
});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
});
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
// Verify completeOAuthFlow was NOT called (prevented duplicate)
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
});
});
describe('GET /oauth/tokens/:flowId', () => {
@@ -1242,7 +1347,9 @@ describe('MCP Routes', () => {
mcpServersRegistry.getServerConfig.mockResolvedValue({});
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);

View File

@@ -9,6 +9,8 @@ const {
PermissionTypes,
actionDelimiter,
removeNullishValues,
validateActionDomain,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
@@ -83,6 +85,32 @@ router.post(
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const appConfig = req.config;
// SECURITY: Validate the OpenAPI spec and extract the server URL
if (metadata.raw_spec) {
const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
if (!validationResult.status || !validationResult.serverUrl) {
return res.status(400).json({
message: validationResult.message || 'Invalid OpenAPI specification',
});
}
// SECURITY: Validate the client-provided domain matches the spec's server URL domain
// This prevents SSRF attacks where an attacker provides a whitelisted domain
// but uses a different (potentially internal) URL in the raw_spec
const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
if (!domainValidation.isValid) {
logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
userId: req.user.id,
agent_id,
});
return res.status(400).json({
message:
'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
});
}
}
const isDomainAllowed = await isActionDomainAllowed(
metadata.domain,
appConfig?.actions?.allowedDomains,

View File

@@ -10,8 +10,8 @@ const {
ResourceType,
EModelEndpoint,
PermissionBits,
isAgentsEndpoint,
checkOpenAIStorage,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const {
filterFile,
@@ -376,11 +376,11 @@ router.post('/', async (req, res) => {
metadata.temp_file_id = metadata.file_id;
metadata.file_id = req.file_id;
if (isAgentsEndpoint(metadata.endpoint)) {
return await processAgentFileUpload({ req, res, metadata });
if (isAssistantsEndpoint(metadata.endpoint)) {
return await processFileUpload({ req, res, metadata });
}
await processFileUpload({ req, res, metadata });
return await processAgentFileUpload({ req, res, metadata });
} catch (error) {
let message = 'Error processing file';
logger.error('[/files] Error processing file:', error);

View File

@@ -3,7 +3,11 @@ const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { sanitizeFilename } = require('@librechat/api');
const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider');
const {
mergeFileConfig,
getEndpointFileConfig,
fileConfig: defaultFileConfig,
} = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
const storage = multer.diskStorage({
@@ -53,12 +57,14 @@ const createFileFilter = (customFileConfig) => {
}
const endpoint = req.body.endpoint;
const supportedTypes =
customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ??
customFileConfig?.endpoints?.default.supportedMimeTypes ??
defaultFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes;
const endpointType = req.body.endpointType;
const endpointFileConfig = getEndpointFileConfig({
fileConfig: customFileConfig,
endpoint,
endpointType,
});
if (!defaultFileConfig.checkType(file.mimetype, supportedTypes)) {
if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
return cb(new Error('Unsupported file type: ' + file.mimetype), false);
}

View File

@@ -134,6 +134,16 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
hasCodeVerifier: !!flowState.codeVerifier,
});
/** Check if this flow has already been completed (idempotency protection) */
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (currentFlowState?.status === 'COMPLETED') {
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
flowId,
serverName,
});
return res.redirect(`/oauth/success?serverName=${encodeURIComponent(serverName)}`);
}
logger.debug('[MCP OAuth] Completing OAuth flow');
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);

View File

@@ -1,4 +1,5 @@
const express = require('express');
const { unescapeLaTeX } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ContentTypes } = require('librechat-data-provider');
const {
@@ -134,17 +135,32 @@ router.post('/artifact/:messageId', async (req, res) => {
return res.status(400).json({ error: 'Artifact index out of bounds' });
}
// Unescape LaTeX preprocessing done by the frontend
// The frontend escapes $ signs for display, but the database has unescaped versions
const unescapedOriginal = unescapeLaTeX(original);
const unescapedUpdated = unescapeLaTeX(updated);
const targetArtifact = artifacts[index];
let updatedText = null;
if (targetArtifact.source === 'content') {
const part = message.content[targetArtifact.partIndex];
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
updatedText = replaceArtifactContent(
part.text,
targetArtifact,
unescapedOriginal,
unescapedUpdated,
);
if (updatedText) {
part.text = updatedText;
}
} else {
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
updatedText = replaceArtifactContent(
message.text,
targetArtifact,
unescapedOriginal,
unescapedUpdated,
);
if (updatedText) {
message.text = updatedText;
}

View File

@@ -109,7 +109,7 @@ async function getEndpointsConfig(req) {
* @returns {Promise<boolean>}
*/
const checkCapability = async (req, capability) => {
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint);
const endpointsConfig = await getEndpointsConfig(req);
const capabilities =
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null

View File

@@ -1,5 +1,9 @@
const { isUserProvided, normalizeEndpointName } = require('@librechat/api');
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
const { isUserProvided } = require('@librechat/api');
const {
EModelEndpoint,
extractEnvVariable,
normalizeEndpointName,
} = require('librechat-data-provider');
const { fetchModels } = require('~/server/services/ModelService');
const { getAppConfig } = require('./app');

View File

@@ -3,12 +3,14 @@ const {
primeResources,
getModelMaxTokens,
extractLibreChatParams,
filterFilesByEndpointConfig,
optionalChainWithEmptyCheck,
} = require('@librechat/api');
const {
ErrorTypes,
EModelEndpoint,
EToolResources,
paramEndpoints,
isAgentsEndpoint,
replaceSpecialVars,
providerEndpointMap,
@@ -71,6 +73,9 @@ const initializeAgent = async ({
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions);
const provider = agent.provider;
agent.endpoint = provider;
if (isInitialAgent && conversationId != null && resendFiles) {
const fileIds = (await getConvoFiles(conversationId)) ?? [];
/** @type {Set<EToolResources>} */
@@ -88,6 +93,19 @@ const initializeAgent = async ({
currentFiles = await processFiles(requestFiles);
}
if (currentFiles && currentFiles.length) {
let endpointType;
if (!paramEndpoints.has(agent.endpoint)) {
endpointType = EModelEndpoint.custom;
}
currentFiles = filterFilesByEndpointConfig(req, {
files: currentFiles,
endpoint: agent.endpoint,
endpointType,
});
}
const { attachments, tool_resources } = await primeResources({
req,
getFiles,
@@ -98,7 +116,6 @@ const initializeAgent = async ({
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
});
const provider = agent.provider;
const {
tools: structuredTools,
toolContextMap,
@@ -113,7 +130,6 @@ const initializeAgent = async ({
tool_resources,
})) ?? {};
agent.endpoint = provider;
const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
if (overrideProvider !== agent.provider) {
agent.provider = overrideProvider;

View File

@@ -1,12 +1,14 @@
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, validateImage } = require('@librechat/api');
const {
FileSources,
VisionModes,
ImageDetail,
ContentTypes,
EModelEndpoint,
mergeFileConfig,
getEndpointFileConfig,
} = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -84,11 +86,15 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
* Encodes and formats the given files.
* @param {ServerRequest} req - The request object.
* @param {Array<MongoFile>} files - The array of files to encode and format.
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
* @param {object} params - Object containing provider/endpoint information
* @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
* @param {string} [params.endpoint] - Optional: The endpoint for the image
* @param {string} [mode] - Optional: The endpoint mode for the image.
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
*/
async function encodeAndFormat(req, files, endpoint, mode) {
async function encodeAndFormat(req, files, params, mode) {
const { provider, endpoint } = params;
const effectiveEndpoint = endpoint ?? provider;
const promises = [];
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
const encodingMethods = {};
@@ -134,7 +140,7 @@ async function encodeAndFormat(req, files, endpoint, mode) {
} catch (error) {
logger.error('Error processing image from blob storage:', error);
}
} else if (source !== FileSources.local && base64Only.has(endpoint)) {
} else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) {
const [_file, imageURL] = await preparePayload(req, file);
promises.push([_file, await fetchImageToBase64(imageURL)]);
continue;
@@ -148,6 +154,17 @@ async function encodeAndFormat(req, files, endpoint, mode) {
const formattedImages = await Promise.all(promises);
promises.length = 0;
/** Extract configured file size limit from fileConfig for this endpoint */
let configuredFileSizeLimit;
if (req.config?.fileConfig) {
const fileConfig = mergeFileConfig(req.config.fileConfig);
const endpointConfig = getEndpointFileConfig({
fileConfig,
endpoint: effectiveEndpoint,
});
configuredFileSizeLimit = endpointConfig?.fileSizeLimit;
}
for (const [file, imageContent] of formattedImages) {
const fileMetadata = {
type: file.type,
@@ -168,6 +185,26 @@ async function encodeAndFormat(req, files, endpoint, mode) {
continue;
}
/** Validate image buffer against size limits */
if (file.height && file.width) {
const imageBuffer = imageContent.startsWith('http')
? null
: Buffer.from(imageContent, 'base64');
if (imageBuffer) {
const validation = await validateImage(
imageBuffer,
imageBuffer.length,
effectiveEndpoint,
configuredFileSizeLimit,
);
if (!validation.isValid) {
throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`);
}
}
}
const imagePart = {
type: ContentTypes.IMAGE_URL,
image_url: {
@@ -184,15 +221,19 @@ async function encodeAndFormat(req, files, endpoint, mode) {
continue;
}
if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) {
if (
effectiveEndpoint &&
effectiveEndpoint === EModelEndpoint.google &&
mode === VisionModes.generative
) {
delete imagePart.image_url;
imagePart.inlineData = {
mimeType: file.type,
data: imageContent,
};
} else if (endpoint && endpoint === EModelEndpoint.google) {
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) {
imagePart.image_url = imagePart.image_url.url;
} else if (endpoint && endpoint === EModelEndpoint.anthropic) {
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) {
imagePart.type = 'image';
imagePart.source = {
type: 'base64',

View File

@@ -15,6 +15,7 @@ const {
checkOpenAIStorage,
removeNullishValues,
isAssistantsEndpoint,
getEndpointFileConfig,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
@@ -994,7 +995,7 @@ async function saveBase64Image(
*/
function filterFile({ req, image, isAvatar }) {
const { file } = req;
const { endpoint, file_id, width, height } = req.body;
const { endpoint, endpointType, file_id, width, height } = req.body;
if (!file_id && !isAvatar) {
throw new Error('No file_id provided');
@@ -1016,9 +1017,13 @@ function filterFile({ req, image, isAvatar }) {
const appConfig = req.config;
const fileConfig = mergeFileConfig(appConfig.fileConfig);
const { fileSizeLimit: sizeLimit, supportedMimeTypes } =
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit;
const endpointFileConfig = getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
});
const fileSizeLimit =
isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit;
if (file.size > fileSizeLimit) {
throw new Error(
@@ -1028,7 +1033,10 @@ function filterFile({ req, image, isAvatar }) {
);
}
const isSupportedMimeType = fileConfig.checkType(file.mimetype, supportedMimeTypes);
const isSupportedMimeType = fileConfig.checkType(
file.mimetype,
endpointFileConfig.supportedMimeTypes,
);
if (!isSupportedMimeType) {
throw new Error('Unsupported file type');

View File

@@ -18,6 +18,7 @@ const {
ImageVisionTool,
openapiToFunction,
AgentCapabilities,
validateActionDomain,
defaultAgentCapabilities,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
@@ -236,12 +237,26 @@ async function processRequiredActions(client, requiredActions) {
// Validate and parse OpenAPI spec
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec) {
if (!validationResult.spec || !validationResult.serverUrl) {
throw new Error(
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
);
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: client.req.user.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
// Process the OpenAPI spec
const { requestBuilders } = openapiToFunction(validationResult.spec);
@@ -525,10 +540,25 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
// Validate and parse OpenAPI spec once per action set
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec) {
if (!validationResult.spec || !validationResult.serverUrl) {
continue;
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: req.user.id,
agent_id: agent.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,

View File

@@ -93,7 +93,7 @@
"react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.2",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.11.2",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",
@@ -135,10 +135,10 @@
"babel-plugin-root-import": "^6.6.0",
"babel-plugin-transform-import-meta": "^2.3.2",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-jest": "^29.1.0",
"fs-extra": "^11.3.2",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-file-loader": "^1.0.3",
@@ -147,7 +147,7 @@
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.2.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"ts-jest": "^29.4.5",
"typescript": "^5.3.3",
"vite": "^6.4.1",
"vite-plugin-compression2": "^2.2.1",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -3,7 +3,7 @@ import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
import { getLatestText } from '~/utils';
interface ArtifactsContextValue {
export interface ArtifactsContextValue {
isSubmitting: boolean;
latestMessageId: string | null;
latestMessageText: string;
@@ -12,10 +12,15 @@ interface ArtifactsContextValue {
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
interface ArtifactsProviderProps {
children: React.ReactNode;
value?: Partial<ArtifactsContextValue>;
}
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
const { isSubmitting, latestMessage, conversation } = useChatContext();
const latestMessageText = useMemo(() => {
const chatLatestMessageText = useMemo(() => {
return getLatestText({
messageId: latestMessage?.messageId ?? null,
text: latestMessage?.text ?? null,
@@ -23,15 +28,20 @@ export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
} as TMessage);
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
const defaultContextValue = useMemo<ArtifactsContextValue>(
() => ({
isSubmitting,
latestMessageText,
latestMessageText: chatLatestMessageText,
latestMessageId: latestMessage?.messageId ?? null,
conversationId: conversation?.conversationId ?? null,
}),
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
[defaultContextValue, value],
);
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField } from '~/utils/endpoints';
import { useChatContext } from './ChatContext';
interface DragDropContextValue {

View File

@@ -1,29 +1,76 @@
import React, { createContext, useContext, useState } from 'react';
import React, { createContext, useContext, useState, useMemo } from 'react';
interface EditorContextType {
/**
* Mutation state context - for components that need to know about save/edit status
* Separated from code state to prevent unnecessary re-renders
*/
interface MutationContextType {
isMutating: boolean;
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
* Code state context - for components that need the current code content
* Changes frequently (on every keystroke), so only subscribe if needed
*/
interface CodeContextType {
currentCode?: string;
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const EditorContext = createContext<EditorContextType | undefined>(undefined);
const MutationContext = createContext<MutationContextType | undefined>(undefined);
const CodeContext = createContext<CodeContextType | undefined>(undefined);
/**
* Provides editor state management for artifact code editing
* Split into two contexts to prevent unnecessary re-renders:
* - MutationContext: for save/edit status (changes rarely)
* - CodeContext: for code content (changes on every keystroke)
*/
export function EditorProvider({ children }: { children: React.ReactNode }) {
const [isMutating, setIsMutating] = useState(false);
const [currentCode, setCurrentCode] = useState<string | undefined>();
const mutationValue = useMemo(() => ({ isMutating, setIsMutating }), [isMutating]);
const codeValue = useMemo(() => ({ currentCode, setCurrentCode }), [currentCode]);
return (
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
{children}
</EditorContext.Provider>
<MutationContext.Provider value={mutationValue}>
<CodeContext.Provider value={codeValue}>{children}</CodeContext.Provider>
</MutationContext.Provider>
);
}
export function useEditorContext() {
const context = useContext(EditorContext);
/**
* Hook to access mutation state only
* Use this when you only need to know about save/edit status
*/
export function useMutationState() {
const context = useContext(MutationContext);
if (context === undefined) {
throw new Error('useEditorContext must be used within an EditorProvider');
throw new Error('useMutationState must be used within an EditorProvider');
}
return context;
}
/**
* Hook to access code state only
* Use this when you need the current code content
*/
export function useCodeState() {
const context = useContext(CodeContext);
if (context === undefined) {
throw new Error('useCodeState must be used within an EditorProvider');
}
return context;
}
/**
* @deprecated Use useMutationState() and/or useCodeState() instead
* This hook causes components to re-render on every keystroke
*/
export function useEditorContext() {
const mutation = useMutationState();
const code = useCodeState();
return { ...mutation, ...code };
}

View File

@@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers';
import { logger, extractContent, isArtifactRoute } from '~/utils';
import { artifactsState } from '~/store/artifacts';
import { logger, extractContent } from '~/utils';
import ArtifactButton from './ArtifactButton';
export const artifactPlugin: Pluggable = () => {
@@ -88,7 +88,7 @@ export function Artifact({
lastUpdateTime: now,
};
if (!location.pathname.includes('/c/')) {
if (!isArtifactRoute(location.pathname)) {
return setArtifact(currentArtifact);
}

View File

@@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { getFileType, logger } from '~/utils';
import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -13,8 +13,9 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
const location = useLocation();
const setVisible = useSetRecoilState(store.artifactsVisibility);
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const isSelected = artifact?.id === currentArtifactId;
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
const debouncedSetVisibleRef = useRef(
@@ -36,7 +37,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return;
}
if (!location.pathname.includes('/c/')) {
if (!isArtifactRoute(location.pathname)) {
return;
}
@@ -54,35 +55,52 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return (
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
<button
type="button"
onClick={() => {
if (!location.pathname.includes('/c/')) {
{(() => {
const handleClick = () => {
if (isSelected) {
resetCurrentArtifactId();
setVisible(false);
return;
}
resetCurrentArtifactId();
setVisible(true);
if (artifacts?.[artifact.id] == null) {
setArtifacts(visibleArtifacts);
}
setTimeout(() => {
setCurrentArtifactId(artifact.id);
}, 15);
}}
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
>
<div className="w-fit bg-surface-tertiary p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">
{localize('com_ui_artifact_click')}
};
const buttonClass = cn(
'relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98]',
{
'border-border-medium bg-surface-hover shadow-lg': isSelected,
'border-border-light bg-surface-tertiary shadow-sm': !isSelected,
},
);
const actionLabel = isSelected
? localize('com_ui_click_to_close')
: localize('com_ui_artifact_click');
return (
<button type="button" onClick={handleClick} className={buttonClass}>
<div className="w-fit p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">{actionLabel}</div>
</div>
</div>
</div>
</div>
</div>
</button>
</button>
);
})()}
<br />
</div>
);

View File

@@ -1,5 +1,7 @@
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
import debounce from 'lodash/debounce';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { KeyBinding } from '@codemirror/view';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import {
useSandpack,
SandpackCodeEditor,
@@ -10,116 +12,143 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
const createDebouncedMutation = (
callback: (params: {
index: number;
messageId: string;
original: string;
updated: string;
}) => void,
) => debounce(callback, 500);
const CodeEditor = ({
fileKey,
readOnly,
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
const mutationCallback = useCallback(
(params: { index: number; messageId: string; original: string; updated: string }) => {
editArtifact.mutate(params);
},
[editArtifact],
);
const debouncedMutation = useMemo(
() => createDebouncedMutation(mutationCallback),
[mutationCallback],
);
useEffect(() => {
if (readOnly) {
return;
}
if (isMutating) {
return;
}
if (artifact.index == null) {
return;
}
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
const isNotOriginal =
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdate == null
? true
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
if (artifact.content && isNotOriginal && isNotRepeated) {
setCurrentCode(currentCode);
debouncedMutation({
index: artifact.index,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: currentCode,
});
}
return () => {
debouncedMutation.cancel();
};
}, [
const CodeEditor = memo(
({
fileKey,
artifact.index,
artifact.content,
artifact.messageId,
readOnly,
isMutating,
currentUpdate,
setIsMutating,
sandpack.files,
setCurrentCode,
debouncedMutation,
]);
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const { setCurrentCode } = useCodeState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
className="hljs language-javascript bg-black"
/>
);
};
/**
* Create stable debounced mutation that doesn't depend on changing callbacks
* Use refs to always access the latest values without recreating the debounce
*/
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
useEffect(() => {
artifactRef.current = artifact;
}, [artifact]);
useEffect(() => {
isMutatingRef.current = isMutating;
}, [isMutating]);
useEffect(() => {
currentUpdateRef.current = currentUpdate;
}, [currentUpdate]);
useEffect(() => {
editArtifactRef.current = editArtifact;
}, [editArtifact]);
useEffect(() => {
setCurrentCodeRef.current = setCurrentCode;
}, [setCurrentCode]);
/**
* Create debounced mutation once - never recreate it
* All values are accessed via refs so they're always current
*/
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly) {
return;
}
if (isMutatingRef.current) {
return;
}
if (artifactRef.current.index == null) {
return;
}
const artifact = artifactRef.current;
const artifactIndex = artifact.index;
const isNotOriginal =
code && artifact.content != null && code.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdateRef.current == null
? true
: code != null && code.trim() !== currentUpdateRef.current.trim();
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: artifactIndex,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: code,
});
}
}, 500),
[readOnly],
);
/**
* Listen to Sandpack file changes and trigger debounced mutation
*/
useEffect(() => {
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
if (currentCode) {
debouncedMutation(currentCode);
}
}, [sandpack.files, fileKey, debouncedMutation]);
/**
* Cleanup: cancel pending mutations when component unmounts or artifact changes
*/
useEffect(() => {
return () => {
debouncedMutation.cancel();
};
}, [artifact.id, debouncedMutation]);
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
extensions={[autocompletion()]}
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
className="hljs language-javascript bg-black"
/>
);
},
);
export const ArtifactCodeEditor = function ({
files,
@@ -128,6 +157,7 @@ export const ArtifactCodeEditor = function ({
artifact,
editorRef,
sharedProps,
readOnly: externalReadOnly,
}: {
fileKey: string;
artifact: Artifact;
@@ -135,6 +165,7 @@ export const ArtifactCodeEditor = function ({
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
readOnly?: boolean;
}) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext();
@@ -148,10 +179,11 @@ export const ArtifactCodeEditor = function ({
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template, fileKey]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
const [readOnly, setReadOnly] = useState(initialReadOnly);
useEffect(() => {
setReadOnly(isSubmitting ?? false);
}, [isSubmitting]);
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
}, [isSubmitting, externalReadOnly]);
if (Object.keys(files).length === 0) {
return null;

View File

@@ -1,10 +1,9 @@
import React, { memo, useMemo } from 'react';
import {
SandpackPreview,
SandpackProvider,
import React, { memo, useMemo, type MutableRefObject } from 'react';
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
import type {
SandpackProviderProps,
SandpackPreviewRef,
} from '@codesandbox/sandpack-react/unstyled';
import type { SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
import type { TStartupConfig } from 'librechat-data-provider';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
@@ -22,7 +21,7 @@ export const ArtifactPreview = memo(function ({
fileKey: string;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
previewRef: MutableRefObject<SandpackPreviewRef>;
currentCode?: string;
startupConfig?: TStartupConfig;
}) {
@@ -36,9 +35,7 @@ export const ArtifactPreview = memo(function ({
}
return {
...files,
[fileKey]: {
code,
},
[fileKey]: { code },
};
}, [currentCode, files, fileKey]);
@@ -46,12 +43,10 @@ export const ArtifactPreview = memo(function ({
if (!startupConfig) {
return sharedOptions;
}
const _options: typeof sharedOptions = {
return {
...sharedOptions,
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
};
return _options;
}, [startupConfig, template]);
if (Object.keys(artifactFiles).length === 0) {
@@ -60,10 +55,7 @@ export const ArtifactPreview = memo(function ({
return (
<SandpackProvider
files={{
...artifactFiles,
...sharedFiles,
}}
files={{ ...artifactFiles, ...sharedFiles }}
options={options}
{...sharedProps}
template={template}

View File

@@ -1,28 +1,32 @@
import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { Artifact } from '~/common';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import { useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
import { cn } from '~/utils';
export default function ArtifactTabs({
artifact,
editorRef,
previewRef,
isSharedConvo,
}: {
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) {
const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useEditorContext();
const { currentCode, setCurrentCode } = useCodeState();
const { data: startupConfig } = useGetStartupConfig();
const lastIdRef = useRef<string | null>(null);
useEffect(() => {
if (artifact.id !== lastIdRef.current) {
setCurrentCode(undefined);
@@ -33,14 +37,16 @@ export default function ArtifactTabs({
const content = artifact.content ?? '';
const contentRef = useRef<HTMLDivElement>(null);
useAutoScroll({ ref: contentRef, content, isSubmitting });
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return (
<>
<div className="flex h-full w-full flex-col">
<Tabs.Content
ref={contentRef}
value="code"
id="artifacts-code"
className={cn('flex-grow overflow-auto')}
className="h-full w-full flex-grow overflow-auto"
tabIndex={-1}
>
<ArtifactCodeEditor
@@ -50,9 +56,11 @@ export default function ArtifactTabs({
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
readOnly={isSharedConvo}
/>
</Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
<ArtifactPreview
files={files}
fileKey={fileKey}
@@ -63,6 +71,6 @@ export default function ArtifactTabs({
startupConfig={startupConfig}
/>
</Tabs.Content>
</>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { MenuButton } from '@ariakit/react';
import { History, Check } from 'lucide-react';
import { DropdownPopup, TooltipAnchor, Button, useMediaQuery } from '@librechat/client';
import { useLocalize } from '~/hooks';
interface ArtifactVersionProps {
currentIndex: number;
totalVersions: number;
onVersionChange: (index: number) => void;
}
export default function ArtifactVersion({
currentIndex,
totalVersions,
onVersionChange,
}: ArtifactVersionProps) {
const localize = useLocalize();
const [isPopoverActive, setIsPopoverActive] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const menuId = 'version-dropdown-menu';
const handleValueChange = (value: string) => {
const index = parseInt(value, 10);
onVersionChange(index);
setIsPopoverActive(false);
};
if (totalVersions <= 1) {
return null;
}
const options = Array.from({ length: totalVersions }, (_, index) => ({
value: index.toString(),
label: localize('com_ui_version_var', { 0: String(index + 1) }),
}));
const dropdownItems = options.map((option) => {
const isSelected = option.value === String(currentIndex);
return {
label: option.label,
onClick: () => handleValueChange(option.value),
value: option.value,
icon: isSelected ? (
<Check size={16} className="text-text-primary" aria-hidden="true" />
) : undefined,
};
});
return (
<DropdownPopup
menuId={menuId}
portal
focusLoop
unmountOnHide
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<TooltipAnchor
description={localize('com_ui_change_version')}
render={
<Button size="icon" variant="ghost" asChild>
<MenuButton>
<History
size={18}
className="text-text-secondary"
aria-hidden="true"
focusable="false"
/>
</MenuButton>
</Button>
}
/>
}
items={dropdownItems}
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
/>
);
}

View File

@@ -1,147 +1,334 @@
import { useRef, useState, useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
import { useEditorContext } from '~/Providers';
import ArtifactVersion from './ArtifactVersion';
import ArtifactTabs from './ArtifactTabs';
import { CopyCodeButton } from './Code';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const MAX_BLUR_AMOUNT = 32;
const MAX_BACKDROP_OPACITY = 0.3;
export default function Artifacts() {
const localize = useLocalize();
const { isMutating } = useEditorContext();
const { isMutating } = useMutationState();
const { isSharedConvo } = useShareContext();
const isMobile = useMediaQuery('(max-width: 868px)');
const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [height, setHeight] = useState(90);
const [isDragging, setIsDragging] = useState(false);
const [blurAmount, setBlurAmount] = useState(0);
const dragStartY = useRef(0);
const dragStartHeight = useRef(90);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const tabOptions = [
{
value: 'code',
label: localize('com_ui_code'),
icon: <Code className="size-4" />,
},
{
value: 'preview',
label: localize('com_ui_preview'),
icon: <Play className="size-4" />,
},
];
useEffect(() => {
setIsVisible(true);
}, []);
setIsMounted(true);
const delay = isMobile ? 50 : 30;
const timer = setTimeout(() => setIsVisible(true), delay);
return () => {
clearTimeout(timer);
setIsMounted(false);
};
}, [isMobile]);
useEffect(() => {
if (!isMobile) {
setBlurAmount(0);
return;
}
const minHeightForBlur = 50;
const maxHeightForBlur = 100;
if (height <= minHeightForBlur) {
setBlurAmount(0);
} else if (height >= maxHeightForBlur) {
setBlurAmount(MAX_BLUR_AMOUNT);
} else {
const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur);
setBlurAmount(Math.round(progress * MAX_BLUR_AMOUNT));
}
}, [height, isMobile]);
const {
activeTab,
setActiveTab,
currentIndex,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
setCurrentArtifactId,
} = useArtifacts();
if (currentArtifact === null || currentArtifact === undefined) {
const handleDragStart = (e: React.PointerEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = height;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handleDragMove = (e: React.PointerEvent) => {
if (!isDragging) {
return;
}
const deltaY = dragStartY.current - e.clientY;
const viewportHeight = window.innerHeight;
const deltaPercentage = (deltaY / viewportHeight) * 100;
const newHeight = Math.max(10, Math.min(100, dragStartHeight.current + deltaPercentage));
setHeight(newHeight);
};
const handleDragEnd = (e: React.PointerEvent) => {
if (!isDragging) {
return;
}
setIsDragging(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
// Snap to positions based on final height
if (height < 30) {
closeArtifacts();
} else if (height > 95) {
setHeight(100);
} else if (height < 60) {
setHeight(50);
} else {
setHeight(90);
}
};
if (!currentArtifact || !isMounted) {
return null;
}
const handleRefresh = () => {
setIsRefreshing(true);
const client = previewRef.current?.getClient();
if (client != null) {
if (client) {
client.dispatch({ type: 'refresh' });
}
setTimeout(() => setIsRefreshing(false), 750);
};
const closeArtifacts = () => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
if (isMobile) {
setIsClosing(true);
setIsVisible(false);
setTimeout(() => {
setArtifactsVisible(false);
setIsClosing(false);
setHeight(90);
}, 250);
} else {
resetCurrentArtifactId();
setArtifactsVisible(false);
}
};
const backdropOpacity =
blurAmount > 0
? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY
: 0;
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
{/* Main Parent */}
<div className="flex h-full w-full items-center justify-center">
{/* Main Container */}
<div className="flex h-full w-full flex-col">
{/* Mobile backdrop with dynamic blur */}
{isMobile && (
<div
className={cn(
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
isVisible && !isClosing
? 'transition-all duration-300'
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
)}
style={{
opacity: isVisible && !isClosing ? backdropOpacity : 0,
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
}}
onClick={blurAmount >= 8 ? closeArtifacts : undefined}
aria-hidden="true"
/>
)}
<div
className={cn(
`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out`,
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm',
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
isMobile
? cn(
'fixed inset-x-0 bottom-0 z-[100] rounded-t-[20px] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
isVisible && !isClosing
? 'translate-y-0 opacity-100'
: 'duration-250 translate-y-full opacity-0 transition-all',
isDragging ? '' : 'transition-all duration-300',
)
: cn(
'h-full shadow-2xl',
isVisible && !isClosing
? 'duration-350 translate-x-0 opacity-100 transition-all'
: 'translate-x-5 opacity-0 transition-all duration-300',
),
)}
style={isMobile ? { height: `${height}vh` } : { overflow: 'hidden' }}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
<div className="flex items-center">
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
<ArrowLeft className="h-4 w-4" />
</button>
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
{isMobile && (
<div
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
</div>
<div className="flex items-center">
{/* Refresh button */}
)}
{/* Header */}
<div
className={cn(
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
isMobile ? 'justify-center' : 'overflow-hidden',
)}
>
{!isMobile && (
<div
className={cn(
'flex items-center transition-all duration-500',
isVisible && !isClosing
? 'translate-x-0 opacity-100'
: '-translate-x-2 opacity-0',
)}
>
<Radio
options={tabOptions}
value={activeTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
/>
</div>
)}
<div
className={cn(
'flex items-center gap-2 transition-all duration-500',
isMobile ? 'min-w-max' : '',
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
)}
>
{activeTab === 'preview' && (
<button
className={cn(
'mr-2 text-text-secondary transition-transform duration-500 ease-in-out',
isRefreshing ? 'rotate-180' : '',
)}
<Button
size="icon"
variant="ghost"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label="Refresh"
aria-label={localize('com_ui_refresh')}
>
<RefreshCw
size={16}
className={cn('transform', isRefreshing ? 'animate-spin' : '')}
/>
</button>
{isRefreshing ? (
<Spinner size={16} />
) : (
<RefreshCw size={16} className="transition-transform duration-200" />
)}
</Button>
)}
{activeTab !== 'preview' && isMutating && (
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
<RefreshCw size={16} className="animate-spin text-text-secondary" />
)}
{orderedArtifactIds.length > 1 && (
<ArtifactVersion
currentIndex={currentIndex}
totalVersions={orderedArtifactIds.length}
onVersionChange={(index) => {
const target = orderedArtifactIds[index];
if (target) {
setCurrentArtifactId(target);
}
}}
/>
)}
{/* Tabs */}
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
<Tabs.Trigger
value="preview"
disabled={isMutating}
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
{localize('com_ui_preview')}
</Tabs.Trigger>
<Tabs.Trigger
value="code"
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
{localize('com_ui_code')}
</Tabs.Trigger>
</Tabs.List>
<button className="text-text-secondary" onClick={closeArtifacts}>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Content */}
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
<div className="flex items-center">
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs">{`${currentIndex + 1} / ${
orderedArtifactIds.length
}`}</span>
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">
<CopyCodeButton content={currentArtifact.content ?? ''} />
{/* Download Button */}
<DownloadArtifact artifact={currentArtifact} />
{/* Publish button */}
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
Publish
</button> */}
<Button
size="icon"
variant="ghost"
onClick={closeArtifacts}
aria-label={localize('com_ui_close')}
>
<X size={16} />
</Button>
</div>
</div>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
<div className="absolute inset-0 flex flex-col">
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
isSharedConvo={isSharedConvo}
/>
</div>
<div
className={cn(
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
)}
aria-hidden={!isRefreshing}
role="status"
>
<div
className={cn(
'transition-transform duration-300 ease-in-out',
isRefreshing ? 'scale-100' : 'scale-95',
)}
>
<Spinner size={24} />
</div>
</div>
</div>
{isMobile && (
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
<Radio
fullWidth
options={tabOptions}
value={activeTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
/>
</div>
)}
</div>
</div>
</Tabs.Root>

View File

@@ -2,8 +2,9 @@ import React, { memo, useEffect, useRef, useState } from 'react';
import copy from 'copy-to-clipboard';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import { Button } from '@librechat/client';
import rehypeHighlight from 'rehype-highlight';
import { Clipboard, CheckMark } from '@librechat/client';
import { Copy, CircleCheckBig } from 'lucide-react';
import { handleDoubleClick, langSubset } from '~/utils';
import { useLocalize } from '~/hooks';
@@ -107,12 +108,13 @@ export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
};
return (
<button
className="mr-2 text-text-secondary"
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
</button>
{isCopied ? <CircleCheckBig size={16} /> : <Copy size={16} />}
</Button>
);
};

View File

@@ -1,20 +1,14 @@
import React, { useState } from 'react';
import { Download } from 'lucide-react';
import { Download, CircleCheckBig } from 'lucide-react';
import type { Artifact } from '~/common';
import { CheckMark } from '@librechat/client';
import { Button } from '@librechat/client';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useEditorContext } from '~/Providers';
import { useCodeState } from '~/Providers/EditorContext';
import { useLocalize } from '~/hooks';
const DownloadArtifact = ({
artifact,
className = '',
}: {
artifact: Artifact;
className?: string;
}) => {
const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
const localize = useLocalize();
const { currentCode } = useEditorContext();
const { currentCode } = useCodeState();
const [isDownloaded, setIsDownloaded] = useState(false);
const { fileKey: fileName } = useArtifactProps({ artifact });
@@ -41,13 +35,14 @@ const DownloadArtifact = ({
};
return (
<button
className={`mr-2 text-text-secondary ${className}`}
<Button
size="icon"
variant="ghost"
onClick={handleDownload}
aria-label={localize('com_ui_download_artifact')}
>
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
</button>
{isDownloaded ? <CircleCheckBig size={16} /> : <Download size={16} />}
</Button>
);
};

View File

@@ -301,7 +301,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)}
<div
className={cn(
'items-between flex gap-2 pb-2',
'@container items-between flex gap-2 pb-2',
isRTL ? 'flex-row-reverse' : 'flex-row',
)}
>

View File

@@ -5,12 +5,12 @@ import {
EModelEndpoint,
mergeFileConfig,
isAgentsEndpoint,
getEndpointField,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig, TConversation } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField } from '~/utils/endpoints';
import AttachFileMenu from './AttachFileMenu';
import AttachFile from './AttachFile';
@@ -26,7 +26,7 @@ function AttachFileChat({
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
@@ -39,9 +39,23 @@ function AttachFileChat({
);
}, [endpoint, endpointsConfig]);
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
const endpointFileConfig = useMemo(
() =>
getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
}),
[endpoint, fileConfig, endpointType],
);
const endpointSupportsFiles: boolean = useMemo(
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,
[endpointType, endpoint],
);
const isUploadDisabled = useMemo(
() => (disableInputs || endpointFileConfig?.disabled) ?? false,
[disableInputs, endpointFileConfig?.disabled],
);
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;

View File

@@ -61,13 +61,8 @@ const AttachFileMenu = ({
ephemeralAgentByConvoId(conversationId),
);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
});
const { handleFileChange } = useFileHandling();
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
toolResource,
});

View File

@@ -1,10 +1,8 @@
import { useRecoilState } from 'recoil';
import { useState } from 'react';
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { Root, Anchor } from '@radix-ui/react-popover';
import { PluginStoreDialog, TooltipAnchor } from '@librechat/client';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider';
import { isParamEndpoint, getEndpointField, tConvoUpdateSchema } from 'librechat-data-provider';
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
import { useSetIndexOptions, useLocalize } from '~/hooks';
@@ -12,8 +10,6 @@ import { useGetEndpointsQuery } from '~/data-provider';
import OptionsPopover from './OptionsPopover';
import PopoverButtons from './PopoverButtons';
import { useChatContext } from '~/Providers';
import { getEndpointField } from '~/utils';
import store from '~/store';
export default function HeaderOptions({
interfaceConfig,
@@ -23,36 +19,11 @@ export default function HeaderOptions({
const { data: endpointsConfig } = useGetEndpointsQuery();
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
);
const localize = useLocalize();
const { showPopover, conversation, setShowPopover } = useChatContext();
const { setOption } = useSetIndexOptions();
const { endpoint, conversationId } = conversation ?? {};
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
const userProvidesKey = useMemo(
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
[endpointsConfig, endpoint],
);
const keyProvided = useMemo(
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
[keyExpiry.expiresAt, userProvidesKey],
);
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
[EModelEndpoint.chatGPTBrowser]: true,
}),
[conversationId],
);
useEffect(() => {
if (endpoint && noSettings[endpoint]) {
setShowPopover(false);
}
}, [endpoint, noSettings]);
const { endpoint } = conversation ?? {};
const saveAsPreset = () => {
setSaveAsDialogShow(true);
@@ -76,22 +47,20 @@ export default function HeaderOptions({
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div className="z-[61] flex w-full items-center justify-center gap-2">
{!noSettings[endpoint] &&
interfaceConfig?.parameters === true &&
paramEndpoint === false && (
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
{interfaceConfig?.parameters === true && paramEndpoint === false && (
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
</div>
{interfaceConfig?.parameters === true && paramEndpoint === false && (
<OptionsPopover
@@ -122,12 +91,6 @@ export default function HeaderOptions({
}
/>
)}
{interfaceConfig?.parameters === true && (
<PluginStoreDialog
isOpen={showPluginStoreDialog}
setIsOpen={setShowPluginStoreDialog}
/>
)}
</span>
</div>
</Anchor>

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { getEndpointField } from '~/utils';
interface DialogManagerProps {
keyDialogOpen: boolean;

View File

@@ -1,7 +1,8 @@
import React, { memo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import type { IconMapProps } from '~/common';
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
import { getModelSpecIconURL, getIconKey } from '~/utils';
import { URLIcon } from '~/components/Endpoints/URLIcon';
import { icons } from '~/hooks/Endpoint/Icons';

View File

@@ -1,20 +1,21 @@
import { useRecoilValue } from 'recoil';
import { Close } from '@radix-ui/react-popover';
import { Flipper, Flipped } from 'react-flip-toolkit';
import { getEndpointField } from 'librechat-data-provider';
import {
Dialog,
DialogTrigger,
Label,
DialogTemplate,
PinIcon,
EditIcon,
TrashIcon,
DialogTrigger,
DialogTemplate,
} from '@librechat/client';
import type { TPreset } from 'librechat-data-provider';
import type { FC } from 'react';
import { getPresetTitle, getEndpointField, getIconKey } from '~/utils';
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
import { useGetEndpointsQuery } from '~/data-provider';
import { getPresetTitle, getIconKey } from '~/utils';
import { MenuSeparator, MenuItem } from '../UI';
import { icons } from '~/hooks/Endpoint/Icons';
import { useLocalize } from '~/hooks';

View File

@@ -144,6 +144,7 @@ const Part = memo(
attachments={attachments}
auth={toolCall.auth}
expires_at={toolCall.expires_at}
isLast={isLast}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
@@ -192,6 +193,7 @@ const Part = memo(
args={toolCall.function.arguments as string}
name={toolCall.function.name}
output={toolCall.function.output}
isLast={isLast}
/>
);
}

View File

@@ -68,25 +68,27 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
return (
<div className="group/reasoning">
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
onClick={handleClick}
label={label}
content={reasoningText}
/>
</div>
<div
className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
)}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent>{reasoningText}</ThinkingContent>
<div className="group/thinking-container">
<div className="sticky top-0 z-10 mb-2 bg-presentation pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
onClick={handleClick}
label={label}
content={reasoningText}
/>
</div>
<div
className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
)}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent>{reasoningText}</ThinkingContent>
</div>
</div>
</div>
</div>

View File

@@ -35,11 +35,13 @@ export const ThinkingButton = memo(
onClick,
label,
content,
showCopyButton = true,
}: {
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
label: string;
content?: string;
showCopyButton?: boolean;
}) => {
const localize = useLocalize();
const fontSize = useAtomValue(fontSizeAtom);
@@ -59,7 +61,7 @@ export const ThinkingButton = memo(
);
return (
<div className="flex w-full items-center justify-between gap-2">
<div className="group/thinking flex w-full items-center justify-between gap-2">
<button
type="button"
onClick={onClick}
@@ -79,7 +81,7 @@ export const ThinkingButton = memo(
</span>
{label}
</button>
{content && (
{content && showCopyButton && (
<button
type="button"
onClick={handleCopy}
@@ -90,8 +92,11 @@ export const ThinkingButton = memo(
}
className={cn(
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
isExpanded
? 'opacity-0 group-focus-within/thinking-container:opacity-100 group-hover/thinking-container:opacity-100'
: 'opacity-0',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
)}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
@@ -142,8 +147,8 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
}
return (
<>
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
<div className="group/thinking-container">
<div className="sticky top-0 z-10 mb-4 bg-presentation pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
onClick={handleClick}
@@ -161,7 +166,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
<ThinkingContent>{children}</ThinkingContent>
</div>
</div>
</>
</div>
);
});

View File

@@ -11,6 +11,7 @@ import { logger, cn } from '~/utils';
export default function ToolCall({
initialProgress = 0.1,
isLast = false,
isSubmitting,
name,
args: _args = '',
@@ -19,6 +20,7 @@ export default function ToolCall({
auth,
}: {
initialProgress: number;
isLast?: boolean;
isSubmitting: boolean;
name: string;
args: string | Record<string, unknown>;
@@ -155,6 +157,10 @@ export default function ToolCall({
};
}, [showInfo, isAnimating]);
if (!isLast && (!function_name || function_name.length === 0) && !output) {
return null;
}
return (
<>
<div className="relative my-2.5 flex h-5 shrink-0 items-center gap-2.5">

View File

@@ -1,9 +1,10 @@
import React, { useMemo, memo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type { Assistant, Agent } from 'librechat-data-provider';
import type { TMessageIcon } from '~/common';
import { getEndpointField, getIconEndpoint, logger } from '~/utils';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useGetEndpointsQuery } from '~/data-provider';
import { getIconEndpoint, logger } from '~/utils';
import Icon from '~/components/Endpoints/Icon';
const MessageIcon = memo(

View File

@@ -18,16 +18,20 @@ export default function MinimalHoverButtons({ message, searchResults }: THoverBu
});
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
<div className="visible mt-1 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
<button
className="ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
className="ml-0 flex items-center gap-1.5 rounded-lg p-1.5 text-xs text-text-secondary-alt transition-colors duration-200 hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
onClick={() => copyToClipboard(setIsCopied)}
type="button"
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{isCopied ? (
<CheckMark className="h-[19px] w-[19px]" />
) : (
<Clipboard className="h-[19px] w-[19px]" />
)}
</button>
</div>
);

View File

@@ -57,6 +57,23 @@ export default function Presentation({ children }: { children: React.ReactNode }
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
/**
* Memoize artifacts JSX to prevent recreating it on every render
* This is critical for performance - prevents entire artifact tree from re-rendering
*/
const artifactsElement = useMemo(() => {
if (artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0) {
return (
<ArtifactsProvider>
<EditorProvider>
<Artifacts />
</EditorProvider>
</ArtifactsProvider>
);
}
return null;
}, [artifactsVisibility, artifacts]);
return (
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
<SidePanelProvider>
@@ -64,15 +81,7 @@ export default function Presentation({ children }: { children: React.ReactNode }
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
artifacts={
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
<ArtifactsProvider>
<EditorProvider>
<Artifacts />
</EditorProvider>
</ArtifactsProvider>
) : null
}
artifacts={artifactsElement}
>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{children}

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils';
import { getIconKey, getEntity, getIconEndpoint } from '~/utils';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { icons } from '~/hooks/Endpoint/Icons';

View File

@@ -1,13 +1,13 @@
import { isAssistantsEndpoint } from 'librechat-data-provider';
import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider';
import type {
TConversation,
TEndpointsConfig,
TPreset,
TConversation,
TAssistantsMap,
TEndpointsConfig,
} from 'librechat-data-provider';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { getEndpointField, getIconEndpoint } from '~/utils';
import { getIconEndpoint } from '~/utils';
export default function EndpointIcon({
conversation,

View File

@@ -1,10 +1,11 @@
import { useRecoilValue } from 'recoil';
import { SettingsViews, TConversation } from 'librechat-data-provider';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import { getEndpointField, SettingsViews } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { TSettingsProps } from '~/common';
import { useGetEndpointsQuery } from '~/data-provider';
import { cn, getEndpointField } from '~/utils';
import { getSettings } from './Settings';
import { cn } from '~/utils';
import store from '~/store';
export default function Settings({

View File

@@ -25,7 +25,7 @@ type EndpointIcon = {
function getOpenAIColor(_model: string | null | undefined) {
const model = _model?.toLowerCase() ?? '';
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) {
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9](?:\.\d+)?\b/i.test(model))) {
return '#000000';
}
return model.includes('gpt-4') ? '#AB68FF' : '#19C37D';

View File

@@ -43,6 +43,7 @@ const errorMessages = {
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
[ErrorTypes.REFUSAL]: 'com_error_refusal',
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
const { info: endpoint } = json;
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';

View File

@@ -68,9 +68,11 @@ export const ThemeSelector = ({
export const LangSelector = ({
langcode,
onChange,
portal = true,
}: {
langcode: string;
onChange: (value: string) => void;
portal?: boolean;
}) => {
const localize = useLocalize();
@@ -124,10 +126,11 @@ export const LangSelector = ({
<Dropdown
value={langcode}
onChange={onChange}
sizeClasses="[--anchor-max-height:256px]"
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
options={languageOptions}
className="z-50"
aria-labelledby={labelId}
portal={portal}
/>
</div>
);

View File

@@ -4,7 +4,7 @@ import type { TMessage, Assistant, Agent } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getIconEndpoint } from '~/utils';
import { getIconEndpoint, logger } from '~/utils';
export default function MessageIcon(
props: Pick<TMessageProps, 'message' | 'conversation'> & {
@@ -41,7 +41,7 @@ export default function MessageIcon(
}
return result;
}, [assistant, agent, assistantAvatar, agentAvatar]);
console.log('MessageIcon', {
logger.log('MessageIcon', {
endpoint,
iconURL,
assistantName,

View File

@@ -0,0 +1,176 @@
import { useState, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
useMediaQuery,
ResizablePanel,
ResizableHandleAlt,
ResizablePanelGroup,
} from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { ArtifactsContextValue } from '~/Providers';
import { ArtifactsProvider, EditorProvider } from '~/Providers';
import Artifacts from '~/components/Artifacts/Artifacts';
import { getLatestText } from '~/utils';
import store from '~/store';
const DEFAULT_ARTIFACT_PANEL_SIZE = 40;
const SHARE_ARTIFACT_PANEL_STORAGE_KEY = 'share:artifacts-panel-size';
const SHARE_ARTIFACT_PANEL_DEFAULT_KEY = 'share:artifacts-panel-size-default';
/**
* Gets the initial artifact panel size from localStorage or returns default
*/
const getInitialArtifactPanelSize = () => {
if (typeof window === 'undefined') {
return DEFAULT_ARTIFACT_PANEL_SIZE;
}
const defaultSizeString = String(DEFAULT_ARTIFACT_PANEL_SIZE);
const storedDefault = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY);
if (storedDefault !== defaultSizeString) {
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY, defaultSizeString);
window.localStorage.removeItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
return DEFAULT_ARTIFACT_PANEL_SIZE;
}
const stored = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
const parsed = Number(stored);
return Number.isFinite(parsed) ? parsed : DEFAULT_ARTIFACT_PANEL_SIZE;
};
interface ShareArtifactsContainerProps {
messages: TMessage[];
conversationId: string;
mainContent: React.ReactNode;
}
/**
* Container component that manages artifact visibility and layout for shared conversations
*/
export function ShareArtifactsContainer({
messages,
conversationId,
mainContent,
}: ShareArtifactsContainerProps) {
const artifacts = useRecoilValue(store.artifactsState);
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
const isSmallScreen = useMediaQuery('(max-width: 1023px)');
const [artifactPanelSize, setArtifactPanelSize] = useState(getInitialArtifactPanelSize);
const artifactsContextValue = useMemo<ArtifactsContextValue | null>(() => {
const latestMessage =
Array.isArray(messages) && messages.length > 0 ? messages[messages.length - 1] : null;
if (!latestMessage) {
return null;
}
const latestMessageText = getLatestText(latestMessage);
return {
isSubmitting: false,
latestMessageId: latestMessage.messageId ?? null,
latestMessageText,
conversationId: conversationId ?? null,
};
}, [messages, conversationId]);
const shouldRenderArtifacts =
artifactsVisibility === true &&
artifactsContextValue != null &&
Object.keys(artifacts ?? {}).length > 0;
const normalizedArtifactSize = Math.min(60, Math.max(20, artifactPanelSize));
/**
* Handles artifact panel resize and persists size to localStorage
*/
const handleLayoutChange = (sizes: number[]) => {
if (sizes.length < 2) {
return;
}
const newSize = sizes[1];
if (!Number.isFinite(newSize)) {
return;
}
setArtifactPanelSize(newSize);
if (typeof window !== 'undefined') {
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY, newSize.toString());
}
};
if (!shouldRenderArtifacts || !artifactsContextValue) {
return <>{mainContent}</>;
}
if (isSmallScreen) {
return (
<>
{mainContent}
<ShareArtifactsOverlay contextValue={artifactsContextValue} />
</>
);
}
return (
<ResizablePanelGroup
direction="horizontal"
className="flex h-full w-full"
onLayout={handleLayoutChange}
>
<ResizablePanel
defaultSize={100 - normalizedArtifactSize}
minSize={35}
order={1}
id="share-content"
>
{mainContent}
</ResizablePanel>
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
<ResizablePanel
defaultSize={normalizedArtifactSize}
minSize={20}
maxSize={60}
order={2}
id="share-artifacts"
>
<ShareArtifactsPanel contextValue={artifactsContextValue} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
interface ShareArtifactsPanelProps {
contextValue: ArtifactsContextValue;
}
/**
* Panel that renders the artifacts UI within a resizable container
*/
function ShareArtifactsPanel({ contextValue }: ShareArtifactsPanelProps) {
return (
<ArtifactsProvider value={contextValue}>
<EditorProvider>
<div className="flex h-full w-full border-l border-border-light bg-surface-primary shadow-2xl">
<Artifacts />
</div>
</EditorProvider>
</ArtifactsProvider>
);
}
/**
* Mobile overlay that displays artifacts in a fixed position
*/
function ShareArtifactsOverlay({ contextValue }: ShareArtifactsPanelProps) {
return (
<div
className="fixed inset-y-0 right-0 z-40 flex w-full max-w-full sm:max-w-[420px]"
role="complementary"
aria-label="Artifacts panel"
>
<ShareArtifactsPanel contextValue={contextValue} />
</div>
);
}

View File

@@ -1,22 +1,42 @@
import { memo } from 'react';
import { Spinner } from '@librechat/client';
import { memo, useState, useCallback, useContext } from 'react';
import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { useParams } from 'react-router-dom';
import { buildTree } from 'librechat-data-provider';
import { CalendarDays, Settings } from 'lucide-react';
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
import {
Spinner,
Button,
OGDialog,
ThemeContext,
OGDialogTitle,
useMediaQuery,
OGDialogHeader,
OGDialogContent,
OGDialogTrigger,
} from '@librechat/client';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { ShareContext } from '~/Providers';
import MessagesView from './MessagesView';
import Footer from '../Chat/Footer';
import { cn } from '~/utils';
import store from '~/store';
function SharedView() {
const localize = useLocalize();
const { data: config } = useGetStartupConfig();
const { theme, setTheme } = useContext(ThemeContext);
const { shareId } = useParams();
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
const dataTree = data && buildTree({ messages: data.messages });
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
const [langcode, setLangcode] = useRecoilState(store.lang);
// configure document title
let docTitle = '';
if (config?.appTitle != null && data?.title != null) {
@@ -27,6 +47,48 @@ function SharedView() {
useDocumentTitle(docTitle);
const locale =
langcode ||
(typeof navigator !== 'undefined'
? navigator.language || navigator.languages?.[0] || 'en-US'
: 'en-US');
const formattedDate =
data?.createdAt != null
? new Date(data.createdAt).toLocaleDateString(locale, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: null;
const handleThemeChange = useCallback(
(value: string) => {
setTheme(value);
},
[setTheme],
);
const handleLangChange = useCallback(
(value: string) => {
let userLang = value;
if (value === 'auto') {
userLang =
(typeof navigator !== 'undefined'
? navigator.language || navigator.languages?.[0]
: null) ?? 'en-US';
}
requestAnimationFrame(() => {
document.documentElement.lang = userLang;
});
setLangcode(userLang);
Cookies.set('lang', userLang, { expires: 365 });
},
[setLangcode],
);
let content: JSX.Element;
if (isLoading) {
content = (
@@ -37,17 +99,15 @@ function SharedView() {
} else if (data && messagesTree && messagesTree.length !== 0) {
content = (
<>
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-[47rem] md:px-5 lg:px-1 xl:max-w-[55rem] xl:px-5">
<h1 className="text-4xl font-bold">{data.title}</h1>
<div className="border-b border-border-medium pb-6 text-base text-text-secondary">
{new Date(data.createdAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
<ShareHeader
title={data.title}
formattedDate={formattedDate}
theme={theme}
langcode={langcode}
onThemeChange={handleThemeChange}
onLangChange={handleLangChange}
settingsLabel={localize('com_nav_settings')}
/>
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
</>
);
@@ -59,23 +119,124 @@ function SharedView() {
);
}
const footer = (
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<Footer className="relative mx-auto mt-4 flex max-w-[55rem] flex-wrap items-center justify-center gap-2 px-3 pb-4 pt-2 text-center text-xs text-text-secondary" />
</div>
);
const mainContent = (
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
<div className="flex h-full flex-col text-text-primary" role="presentation">
{content}
{footer}
</div>
</div>
);
const artifactsContainer =
data && data.messages ? (
<ShareArtifactsContainer
messages={data.messages}
conversationId={data.conversationId}
mainContent={mainContent}
/>
) : (
mainContent
);
return (
<ShareContext.Provider value={{ isSharedConvo: true }}>
<main
className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary"
style={{ paddingBottom: '50px' }}
>
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
<div className="flex h-full flex-col text-text-primary" role="presentation">
{content}
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-surface-secondary to-transparent px-2 pb-2 pt-8 text-xs text-text-secondary md:px-[60px]" />
</div>
</div>
</div>
</main>
<div className="relative flex min-h-screen w-full dark:bg-surface-secondary">
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
{artifactsContainer}
</main>
</div>
</ShareContext.Provider>
);
}
interface ShareHeaderProps {
title?: string;
formattedDate: string | null;
theme: string;
langcode: string;
settingsLabel: string;
onThemeChange: (value: string) => void;
onLangChange: (value: string) => void;
}
function ShareHeader({
title,
formattedDate,
theme,
langcode,
settingsLabel,
onThemeChange,
onLangChange,
}: ShareHeaderProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const isMobile = useMediaQuery('(max-width: 767px)');
const handleDialogOutside = useCallback((event: Event) => {
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dialog-ignore="true"]')) {
event.preventDefault();
}
}, []);
return (
<section className="mx-auto w-full px-3 pb-4 pt-6 md:px-5">
<div className="bg-surface-primary/80 relative mx-auto flex w-full max-w-[60rem] flex-col gap-4 rounded-3xl border border-border-light px-6 py-5 shadow-xl backdrop-blur">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="space-y-2">
<h1 className="text-4xl font-semibold text-text-primary">{title}</h1>
{formattedDate && (
<div className="flex items-center gap-2 text-sm text-text-secondary">
<CalendarDays className="size-4" aria-hidden="true" />
<span>{formattedDate}</span>
</div>
)}
</div>
<OGDialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<OGDialogTrigger asChild>
<Button
size={isMobile ? 'icon' : 'default'}
type="button"
variant="outline"
aria-label={settingsLabel}
className={cn(
'rounded-full border-border-medium text-sm text-text-primary transition-colors',
isMobile
? 'absolute bottom-4 right-4 justify-center p-0 shadow-lg'
: 'gap-2 self-start px-4 py-2',
)}
>
<Settings className="size-4" aria-hidden="true" />
<span className="hidden md:inline">{settingsLabel}</span>
</Button>
</OGDialogTrigger>
<OGDialogContent
className="w-11/12 max-w-lg"
showCloseButton={true}
onPointerDownOutside={handleDialogOutside}
onInteractOutside={handleDialogOutside}
>
<OGDialogHeader className="text-left">
<OGDialogTitle>{settingsLabel}</OGDialogTitle>
</OGDialogHeader>
<div className="flex flex-col gap-4 pt-2 text-sm">
<ThemeSelector theme={theme} onChange={onThemeChange} />
<div className="bg-border-medium/60 h-px w-full" />
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
</div>
</OGDialogContent>
</OGDialog>
</div>
</div>
</section>
);
}
export default memo(SharedView);

View File

@@ -1,12 +1,11 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useToastContext } from '@librechat/client';
import { EModelEndpoint } from 'librechat-data-provider';
import { Controller, useWatch, useFormContext } from 'react-hook-form';
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
import {
removeFocusOutlines,
processAgentOption,
getEndpointField,
defaultTextProps,
validateEmail,
getIconKey,

View File

@@ -6,9 +6,8 @@ import {
EModelEndpoint,
mergeFileConfig,
AgentCapabilities,
fileConfig as defaultFileConfig,
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import type { ExtendedFile, AgentForm } from '~/common';
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
import FileRow from '~/components/Chat/Input/Files/FileRow';
@@ -30,12 +29,11 @@ export default function Files({
const { watch } = useFormContext<AgentForm>();
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { abortUpload, handleFileChange } = useFileHandling({
fileSetter: setFiles,
overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource },
});
@@ -51,9 +49,11 @@ export default function Files({
const codeChecked = watch(AgentCapabilities.execute_code);
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents] as
| EndpointFileConfig
| undefined;
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.agents,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
if (isUploadDisabled) {

View File

@@ -5,7 +5,7 @@ import {
EModelEndpoint,
EToolResources,
mergeFileConfig,
fileConfig as defaultFileConfig,
getEndpointFileConfig,
} from 'librechat-data-provider';
import {
HoverCard,
@@ -41,17 +41,15 @@ export default function FileContext({
const { data: startupConfig } = useGetStartupConfig();
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource: EToolResources.context },
fileSetter: setFiles,
});
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
fileSetter: setFiles,
});
@@ -65,8 +63,12 @@ export default function FileContext({
750,
);
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
const isUploadDisabled = endpointFileConfig.disabled ?? false;
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.agents,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
try {
await handleSharePointFiles(sharePointFiles);

View File

@@ -8,7 +8,7 @@ import {
EToolResources,
mergeFileConfig,
AgentCapabilities,
fileConfig as defaultFileConfig,
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { ExtendedFile, AgentForm } from '~/common';
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
@@ -38,18 +38,16 @@ export default function FileSearch({
// Get startup configuration for SharePoint feature flag
const { data: startupConfig } = useGetStartupConfig();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
fileSetter: setFiles,
});
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
fileSetter: setFiles,
});
@@ -66,8 +64,12 @@ export default function FileSearch({
const fileSearchChecked = watch(AgentCapabilities.file_search);
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
const isUploadDisabled = endpointFileConfig.disabled ?? false;
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.agents,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const disabledUploadButton = isEphemeralAgent(agent_id) || fileSearchChecked === false;

View File

@@ -7,6 +7,7 @@ import { componentMapping } from '~/components/SidePanel/Parameters/components';
import {
alternateName,
getSettingsKeys,
getEndpointField,
LocalStorageKeys,
SettingDefinition,
agentParamSettings,
@@ -14,9 +15,9 @@ import {
import type * as t from 'librechat-data-provider';
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField, cn } from '~/utils';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
import { cn } from '~/utils';
export default function ModelPanel({
providers,

View File

@@ -0,0 +1,66 @@
import { useRef, useEffect, memo } from 'react';
import { ResizableHandleAlt, ResizablePanel } from '@librechat/client';
import type { ImperativePanelHandle } from 'react-resizable-panels';
interface ArtifactsPanelProps {
artifacts: React.ReactNode | null;
currentLayout: number[];
minSizeMain: number;
shouldRender: boolean;
onRenderChange: (shouldRender: boolean) => void;
}
/**
* ArtifactsPanel component - memoized to prevent unnecessary re-renders
* Only re-renders when artifacts visibility or layout changes
*/
const ArtifactsPanel = memo(function ArtifactsPanel({
artifacts,
currentLayout,
minSizeMain,
shouldRender,
onRenderChange,
}: ArtifactsPanelProps) {
const artifactsPanelRef = useRef<ImperativePanelHandle>(null);
useEffect(() => {
if (artifacts != null) {
onRenderChange(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
artifactsPanelRef.current?.expand();
});
});
} else if (shouldRender) {
onRenderChange(false);
}
}, [artifacts, shouldRender, onRenderChange]);
if (!shouldRender) {
return null;
}
return (
<>
{artifacts != null && (
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
)}
<ResizablePanel
ref={artifactsPanelRef}
defaultSize={artifacts != null ? currentLayout[1] : 0}
minSize={minSizeMain}
maxSize={70}
collapsible={true}
collapsedSize={0}
order={2}
id="artifacts-panel"
>
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>
</ResizablePanel>
</>
);
});
ArtifactsPanel.displayName = 'ArtifactsPanel';
export default ArtifactsPanel;

View File

@@ -1,10 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import {
EToolResources,
mergeFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider';
import { EToolResources, mergeFileConfig, getEndpointFileConfig } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider';
@@ -28,11 +24,10 @@ export default function CodeFiles({
const { setFilesLoading } = useChatContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { handleFileChange } = useFileHandling({
overrideEndpoint: endpoint,
additionalMetadata: { assistant_id, tool_resource },
fileSetter: setFiles,
});
@@ -43,7 +38,11 @@ export default function CodeFiles({
}
}, [_files]);
const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined;
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint,
endpointType: endpoint,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
if (isUploadDisabled) {

View File

@@ -2,9 +2,9 @@ import { useState, useRef, useEffect } from 'react';
import {
mergeFileConfig,
retrievalMimeTypes,
fileConfig as defaultFileConfig,
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider';
@@ -38,11 +38,10 @@ export default function Knowledge({
const { setFilesLoading } = useChatContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { handleFileChange } = useFileHandling({
overrideEndpoint: endpoint,
additionalMetadata: { assistant_id },
fileSetter: setFiles,
});
@@ -53,7 +52,11 @@ export default function Knowledge({
}
}, [_files]);
const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined;
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint,
endpointType: endpoint,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
if (isUploadDisabled) {

View File

@@ -30,6 +30,7 @@ import {
mergeFileConfig,
megabyte,
isAssistantsEndpoint,
getEndpointFileConfig,
type TFile,
} from 'librechat-data-provider';
import { useFileMapContext, useChatContext } from '~/Providers';
@@ -86,7 +87,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
const fileMap = useFileMapContext();
const { showToast } = useToastContext();
const { setFiles, conversation } = useChatContext();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { addFile } = useUpdateFiles(setFiles);
@@ -103,6 +104,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
const fileData = fileMap[file.file_id];
const endpoint = conversation.endpoint;
const endpointType = conversation.endpointType;
if (!fileData.source) {
return;
@@ -126,20 +128,31 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
});
}
const { fileSizeLimit, supportedMimeTypes } =
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint,
endpointType,
});
if (fileData.bytes > fileSizeLimit) {
if (endpointFileConfig.disabled === true) {
showToast({
message: localize('com_ui_attach_error_disabled'),
status: 'error',
});
return;
}
if (fileData.bytes > (endpointFileConfig.fileSizeLimit ?? Number.MAX_SAFE_INTEGER)) {
showToast({
message: `${localize('com_ui_attach_error_size')} ${
fileSizeLimit / megabyte
(endpointFileConfig.fileSizeLimit ?? 0) / megabyte
} MB (${endpoint})`,
status: 'error',
});
return;
}
if (!defaultFileConfig.checkType(file.type, supportedMimeTypes)) {
if (!defaultFileConfig.checkType(file.type, endpointFileConfig.supportedMimeTypes ?? [])) {
showToast({
message: `${localize('com_ui_attach_error_type')} ${file.type} (${endpoint})`,
status: 'error',
@@ -162,7 +175,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
metadata: fileData.metadata,
});
},
[addFile, fileMap, conversation, localize, showToast, fileConfig.endpoints],
[addFile, fileMap, conversation, localize, showToast, fileConfig],
);
const filenameFilter = table.getColumn('filename')?.getFilterValue() as string;

View File

@@ -56,7 +56,7 @@ function DynamicSwitch({
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}:{' '}
{defaultValue != null ? 'com_ui_on' : 'com_ui_off'})
{defaultValue != null ? localize('com_ui_on') : localize('com_ui_off')})
</small>
)}
</Label>

View File

@@ -5,6 +5,7 @@ import {
excludedKeys,
paramSettings,
getSettingsKeys,
getEndpointField,
SettingDefinition,
tConvoUpdateSchema,
} from 'librechat-data-provider';
@@ -12,9 +13,9 @@ import type { TPreset } from 'librechat-data-provider';
import { SaveAsPresetDialog } from '~/components/Endpoints';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField, logger } from '~/utils';
import { componentMapping } from './components';
import { useChatContext } from '~/Providers';
import { logger } from '~/utils';
export default function Parameters() {
const localize = useLocalize();

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useMemo, memo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { ResizableHandleAlt, ResizablePanel, useMediaQuery } from '@librechat/client';
import type { TEndpointsConfig, TInterfaceConfig } from 'librechat-data-provider';
@@ -8,7 +9,7 @@ import { useLocalStorage, useLocalize } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import NavToggle from '~/components/Nav/NavToggle';
import { useSidePanelContext } from '~/Providers';
import { cn, getEndpointField } from '~/utils';
import { cn } from '~/utils';
import Nav from './Nav';
const defaultMinSize = 20;
@@ -161,6 +162,9 @@ const SidePanel = ({
transition: 'width 0.2s ease, visibility 0s linear 0.2s',
}}
onExpand={() => {
if (isCollapsed && (fullCollapse || collapsedSize === 0)) {
return;
}
setIsCollapsed(false);
localStorage.setItem('react-resizable-panels:collapsed', 'false');
}}

View File

@@ -2,14 +2,10 @@ import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
import throttle from 'lodash/throttle';
import { useRecoilValue } from 'recoil';
import { getConfigDefaults } from 'librechat-data-provider';
import {
ResizableHandleAlt,
ResizablePanel,
ResizablePanelGroup,
useMediaQuery,
} from '@librechat/client';
import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/client';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useGetStartupConfig } from '~/data-provider';
import ArtifactsPanel from './ArtifactsPanel';
import { normalizeLayout } from '~/utils';
import SidePanel from './SidePanel';
import store from '~/store';
@@ -46,6 +42,7 @@ const SidePanelGroup = memo(
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const hideSidePanel = useRecoilValue(store.hideSidePanel);
@@ -109,7 +106,7 @@ const SidePanelGroup = memo(
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => throttledSaveLayout(sizes)}
className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation"
className="relative h-full w-full flex-1 overflow-auto bg-presentation"
>
<ResizablePanel
defaultSize={currentLayout[0]}
@@ -119,19 +116,17 @@ const SidePanelGroup = memo(
>
{children}
</ResizablePanel>
{artifacts != null && (
<>
<ResizableHandleAlt withHandle className="ml-3 bg-border-medium text-text-primary" />
<ResizablePanel
defaultSize={currentLayout[1]}
minSize={minSizeMain}
order={2}
id="artifacts-panel"
>
{artifacts}
</ResizablePanel>
</>
{!isSmallScreen && (
<ArtifactsPanel
artifacts={artifacts}
currentLayout={currentLayout}
minSizeMain={minSizeMain}
shouldRender={shouldRenderArtifacts}
onRenderChange={setShouldRenderArtifacts}
/>
)}
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<SidePanel
panelRef={panelRef}
@@ -143,12 +138,15 @@ const SidePanelGroup = memo(
setCollapsedSize={setCollapsedSize}
fullCollapse={fullCollapse}
setFullCollapse={setFullCollapse}
defaultSize={currentLayout[currentLayout.length - 1]}
hasArtifacts={artifacts != null}
interfaceConfig={interfaceConfig}
hasArtifacts={shouldRenderArtifacts}
defaultSize={currentLayout[currentLayout.length - 1]}
/>
)}
</ResizablePanelGroup>
{artifacts != null && isSmallScreen && (
<div className="fixed inset-0 z-[100]">{artifacts}</div>
)}
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}

View File

@@ -1,15 +1,52 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, QueryKeys, Constants } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type { UseMutationResult, UseMutationOptions } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
type EditArtifactContext = {
previousMessages: Record<string, t.TMessage[] | undefined>;
updatedConversationId: string | null;
};
export const useEditArtifact = (
_options?: t.EditArtifactOptions,
): UseMutationResult<t.TEditArtifactResponse, Error, t.TEditArtifactRequest> => {
): UseMutationResult<
t.TEditArtifactResponse,
Error,
t.TEditArtifactRequest,
EditArtifactContext
> => {
const queryClient = useQueryClient();
const { onSuccess, ...options } = _options ?? {};
return useMutation({
const { onSuccess, onError, onMutate: userOnMutate, ...options } = _options ?? {};
const mutationOptions: UseMutationOptions<
t.TEditArtifactResponse,
Error,
t.TEditArtifactRequest,
EditArtifactContext
> = {
mutationFn: (variables: t.TEditArtifactRequest) => dataService.editArtifact(variables),
/**
* onMutate: No optimistic updates for artifact editing
* The code editor shows changes instantly via local Sandpack state
* Optimistic updates cause "original content not found" errors because:
* 1. First edit optimistically updates cache
* 2. Artifact.content reflects the updated cache
* 3. Next edit sends updated content as "original" → doesn't match DB → error
*/
onMutate: async (vars) => {
// Call user's onMutate if provided
if (userOnMutate) {
await userOnMutate(vars);
}
return { previousMessages: {}, updatedConversationId: null };
},
onError: (error, vars, context) => {
onError?.(error, vars, context);
},
/**
* On success: Update with server response to ensure consistency
*/
onSuccess: (data, vars, context) => {
let targetNotFound = true;
const setMessageData = (conversationId?: string | null) => {
@@ -50,11 +87,13 @@ export const useEditArtifact = (
console.warn(
'Edited Artifact Message not found in cache, trying `new` as `conversationId`',
);
setMessageData(Constants.NEW_CONVO);
setMessageData(Constants.NEW_CONVO as string);
}
onSuccess?.(data, vars, context);
},
...options,
});
};
return useMutation(mutationOptions);
};

View File

@@ -0,0 +1,693 @@
import { renderHook, act } from '@testing-library/react';
import { Constants } from 'librechat-data-provider';
import type { Artifact } from '~/common';
/** Mock dependencies */
jest.mock('~/Providers', () => ({
useArtifactsContext: jest.fn(),
}));
jest.mock('~/utils', () => ({
logger: {
log: jest.fn(),
},
}));
/** Mock store before importing */
jest.mock('~/store', () => ({
__esModule: true,
default: {
artifactsState: { key: 'artifactsState' },
currentArtifactId: { key: 'currentArtifactId' },
artifactsVisibility: { key: 'artifactsVisibility' },
},
}));
jest.mock('recoil', () => {
const actualRecoil = jest.requireActual('recoil');
return {
...actualRecoil,
useRecoilValue: jest.fn(),
useRecoilState: jest.fn(),
useResetRecoilState: jest.fn(),
};
});
/** Import mocked functions after mocking */
import { useArtifactsContext } from '~/Providers';
import { useRecoilValue, useRecoilState, useResetRecoilState } from 'recoil';
import { logger } from '~/utils';
import useArtifacts from '../useArtifacts';
describe('useArtifacts', () => {
const mockResetArtifacts = jest.fn();
const mockResetCurrentArtifactId = jest.fn();
const mockSetCurrentArtifactId = jest.fn();
const createArtifact = (partial: Partial<Artifact>): Artifact => ({
id: 'artifact-1',
title: 'Test Artifact',
type: 'application/vnd.react',
content: 'const App = () => <div>Test</div>',
messageId: 'msg-1',
lastUpdateTime: Date.now(),
...partial,
});
const defaultContext = {
isSubmitting: false,
latestMessageId: 'msg-1',
latestMessageText: '',
conversationId: 'conv-1',
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
(useArtifactsContext as jest.Mock).mockReturnValue(defaultContext);
(useRecoilValue as jest.Mock).mockReturnValue({});
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
(useResetRecoilState as jest.Mock).mockImplementation((atom) => {
if (atom?.key === 'artifactsState') {
return mockResetArtifacts;
}
if (atom?.key === 'currentArtifactId') {
return mockResetCurrentArtifactId;
}
return jest.fn();
});
});
afterEach(() => {
jest.useRealTimers();
});
describe('initial state', () => {
it('should initialize with preview tab active', () => {
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
it('should return null currentArtifact when no artifacts exist', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useArtifacts());
expect(result.current.currentArtifact).toBeNull();
});
it('should return empty orderedArtifactIds when no artifacts exist', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
const { result } = renderHook(() => useArtifacts());
expect(result.current.orderedArtifactIds).toEqual([]);
});
});
describe('artifact ordering', () => {
it('should order artifacts by lastUpdateTime', () => {
const artifacts = {
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
};
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
const { result } = renderHook(() => useArtifacts());
expect(result.current.orderedArtifactIds).toEqual(['artifact-1', 'artifact-2', 'artifact-3']);
});
it('should automatically select latest artifact', () => {
const artifacts = {
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
};
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
renderHook(() => useArtifacts());
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2');
});
});
describe('tab switching - enclosed artifacts', () => {
it('should switch to preview when enclosed artifact is detected during generation', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: false,
latestMessageText: '',
});
const { result, rerender } = renderHook(() => useArtifacts());
/** Generation starts with enclosed artifact */
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>\n:::',
});
rerender();
/** Should switch to preview when enclosed detected */
expect(result.current.activeTab).toBe('preview');
});
it('should not switch to preview if artifact is not enclosed', () => {
const artifact = createArtifact({
content: 'const App = () => <div>Test</div>',
});
(useRecoilValue as jest.Mock).mockReturnValue({});
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
const { result, rerender } = renderHook(() => useArtifacts());
/** Update with non-enclosed artifact */
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>',
});
rerender();
/** Should switch to code since artifact content is in message and not enclosed */
expect(result.current.activeTab).toBe('code');
expect(logger.log).not.toHaveBeenCalledWith(
'artifacts',
expect.stringContaining('Enclosed artifact'),
);
});
it('should only switch to preview once per artifact', () => {
const artifact = createArtifact({});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
const { rerender } = renderHook(() => useArtifacts());
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Test"}\ncode\n:::',
});
rerender();
const firstCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
call[1]?.includes('Enclosed artifact'),
).length;
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Test"}\ncode\n:::\nMore text',
});
rerender();
const secondCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
call[1]?.includes('Enclosed artifact'),
).length;
expect(secondCallCount).toBe(firstCallCount);
});
});
describe('tab switching - non-enclosed artifacts', () => {
it('should switch to code when non-enclosed artifact content appears', () => {
const artifact = createArtifact({
content: 'const App = () => <div>Test Component</div>',
});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: 'Here is the code: const App = () => <div>Test Component</div>',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('code');
});
it('should not switch to code if artifact content is not in message text', () => {
const artifact = createArtifact({
content: 'const App = () => <div>Test</div>',
});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: 'Some other text here',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
});
describe('conversation changes', () => {
it('should reset artifacts when conversation changes', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
const { rerender } = renderHook(() => useArtifacts());
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
conversationId: 'conv-2',
});
rerender();
expect(mockResetArtifacts).toHaveBeenCalled();
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
});
it('should reset artifacts when navigating to new conversation from another conversation', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
/** Start with existing conversation (NOT Constants.NEW_CONVO) */
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
conversationId: 'existing-conv',
});
const { rerender } = renderHook(() => useArtifacts());
jest.clearAllMocks();
/** Navigate to NEW_CONVO - this should trigger the else if branch */
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
conversationId: Constants.NEW_CONVO,
});
rerender();
expect(mockResetArtifacts).toHaveBeenCalled();
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
});
it('should not reset artifacts on initial render', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
renderHook(() => useArtifacts());
expect(mockResetArtifacts).not.toHaveBeenCalled();
expect(mockResetCurrentArtifactId).not.toHaveBeenCalled();
});
it('should reset when transitioning from null to NEW_CONVO', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
/** Start with null conversationId */
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
conversationId: null,
});
const { rerender } = renderHook(() => useArtifacts());
jest.clearAllMocks();
/** Transition to NEW_CONVO - triggers the else if branch (line 44) */
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
conversationId: Constants.NEW_CONVO,
});
rerender();
/** Should reset because we're now on NEW_CONVO */
expect(mockResetArtifacts).toHaveBeenCalled();
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
});
it('should reset state flags when message ID changes', () => {
const artifact = createArtifact({});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{}\ncode\n:::',
latestMessageId: 'msg-1',
});
const { result, rerender } = renderHook(() => useArtifacts());
// First artifact becomes enclosed
expect(result.current.activeTab).toBe('preview');
// New message starts
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: 'New message',
latestMessageId: 'msg-2',
});
rerender();
// Should allow switching again for the new message
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{}\nnew code\n:::',
latestMessageId: 'msg-2',
});
rerender();
expect(result.current.activeTab).toBe('preview');
});
});
describe('cleanup on unmount', () => {
it('should reset artifacts when unmounting', () => {
(useRecoilValue as jest.Mock).mockReturnValue({});
const { unmount } = renderHook(() => useArtifacts());
unmount();
expect(mockResetArtifacts).toHaveBeenCalled();
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
expect(logger.log).toHaveBeenCalledWith('artifacts_visibility', 'Unmounting artifacts');
});
});
describe('manual tab switching', () => {
it('should allow manually switching tabs', () => {
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
act(() => {
result.current.setActiveTab('code');
});
expect(result.current.activeTab).toBe('code');
});
it('should allow switching back to preview after manual switch to code', () => {
const { result } = renderHook(() => useArtifacts());
act(() => {
result.current.setActiveTab('code');
});
expect(result.current.activeTab).toBe('code');
act(() => {
result.current.setActiveTab('preview');
});
expect(result.current.activeTab).toBe('preview');
});
});
describe('currentIndex calculation', () => {
it('should return correct index for current artifact', () => {
const artifacts = {
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
};
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
(useRecoilState as jest.Mock).mockReturnValue(['artifact-2', mockSetCurrentArtifactId]);
const { result } = renderHook(() => useArtifacts());
expect(result.current.currentIndex).toBe(1);
});
it('should return -1 for non-existent artifact', () => {
const artifacts = {
'artifact-1': createArtifact({ id: 'artifact-1' }),
};
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
(useRecoilState as jest.Mock).mockReturnValue(['non-existent', mockSetCurrentArtifactId]);
const { result } = renderHook(() => useArtifacts());
expect(result.current.currentIndex).toBe(-1);
});
});
describe('complex scenarios', () => {
it('should detect and handle enclosed artifacts during generation', async () => {
/** Start fresh with enclosed artifact already present */
(useRecoilValue as jest.Mock).mockReturnValue({});
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Component"}\nconst App = () => <div>Test</div>\n:::',
});
const { result } = renderHook(() => useArtifacts());
/** Should detect enclosed pattern and be on preview */
expect(result.current.activeTab).toBe('preview');
});
it('should handle multiple artifacts in sequence', () => {
const artifact1 = createArtifact({ id: 'artifact-1', messageId: 'msg-1' });
const artifact2 = createArtifact({ id: 'artifact-2', messageId: 'msg-2' });
/** First artifact */
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact1 });
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{}\ncode1\n:::',
latestMessageId: 'msg-1',
});
const { result, rerender } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
/** Second artifact starts (new message) */
(useRecoilValue as jest.Mock).mockReturnValue({
'artifact-1': artifact1,
'artifact-2': artifact2,
});
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: 'Here is another one',
latestMessageId: 'msg-2',
});
rerender();
/** Second artifact becomes enclosed */
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{}\ncode2\n:::',
latestMessageId: 'msg-2',
});
rerender();
expect(result.current.activeTab).toBe('preview');
});
});
describe('edge cases', () => {
it('should handle null artifacts gracefully', () => {
(useRecoilValue as jest.Mock).mockReturnValue(null);
const { result } = renderHook(() => useArtifacts());
expect(result.current.orderedArtifactIds).toEqual([]);
expect(result.current.currentArtifact).toBeNull();
});
it('should handle undefined artifacts gracefully', () => {
(useRecoilValue as jest.Mock).mockReturnValue(undefined);
const { result } = renderHook(() => useArtifacts());
expect(result.current.orderedArtifactIds).toEqual([]);
expect(result.current.currentArtifact).toBeNull();
});
it('should handle empty latestMessageText', () => {
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: '',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
it('should handle malformed artifact syntax', () => {
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact\ncode but no closing',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
it('should handle artifact with only opening tag', () => {
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Test"}',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
});
describe('artifact content comparison', () => {
it('should not switch tabs when artifact content does not change', () => {
const artifact = createArtifact({});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: 'Some text',
});
const { result, rerender } = renderHook(() => useArtifacts());
const initialTab = result.current.activeTab;
/** Same content, just rerender */
rerender();
expect(result.current.activeTab).toBe(initialTab);
});
});
describe('isSubmitting state handling', () => {
it('should process when isSubmitting is true', () => {
const artifact = createArtifact({});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{}\ncode\n:::',
});
renderHook(() => useArtifacts());
expect(mockSetCurrentArtifactId).toHaveBeenCalled();
});
it('should still select latest artifact even when idle (via orderedArtifactIds effect)', () => {
const artifact = createArtifact({});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: false,
latestMessageText: 'Some text',
});
renderHook(() => useArtifacts());
/** The orderedArtifactIds effect always runs when artifacts change */
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1');
});
it('should not process when latestMessageId is null', () => {
const artifact = createArtifact({});
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageId: null,
latestMessageText: ':::artifact{}\ncode\n:::',
});
const { result } = renderHook(() => useArtifacts());
/** Main effect should exit early and not switch tabs */
expect(result.current.activeTab).toBe('preview');
});
});
describe('regex pattern matching', () => {
it('should match artifact with title attribute', () => {
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="My Component"}\ncode\n:::',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
it('should match artifact with multiple attributes', () => {
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Test" type="react" identifier="comp-1"}\ncode\n:::',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
it('should match artifact with code blocks inside', () => {
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{}\n```typescript\nconst x = 1;\n```\n:::',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
it('should match artifact with whitespace variations', () => {
(useArtifactsContext as jest.Mock).mockReturnValue({
...defaultContext,
isSubmitting: true,
latestMessageText: ':::artifact{title="Test"} \n\n code here \n\n :::',
});
const { result } = renderHook(() => useArtifacts());
expect(result.current.activeTab).toBe('preview');
});
});
});

View File

@@ -1,9 +1,8 @@
import { useMemo, useState, useEffect, useRef } from 'react';
import { Constants } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
import { logger } from '~/utils';
import { useArtifactsContext } from '~/Providers';
import { getKey } from '~/utils/artifacts';
import { logger } from '~/utils';
import store from '~/store';
export default function useArtifacts() {
@@ -22,6 +21,7 @@ export default function useArtifacts() {
);
}, [artifacts]);
const prevIsSubmittingRef = useRef<boolean>(false);
const lastContentRef = useRef<string | null>(null);
const hasEnclosedArtifactRef = useRef<boolean>(false);
const hasAutoSwitchedToCodeRef = useRef<boolean>(false);
@@ -36,6 +36,7 @@ export default function useArtifacts() {
lastRunMessageIdRef.current = null;
lastContentRef.current = null;
hasEnclosedArtifactRef.current = false;
hasAutoSwitchedToCodeRef.current = false;
};
if (conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null) {
resetState();
@@ -57,8 +58,17 @@ export default function useArtifacts() {
}
}, [setCurrentArtifactId, orderedArtifactIds]);
/**
* Manage artifact selection and code tab switching for non-enclosed artifacts
* Runs when artifact content changes
*/
useEffect(() => {
if (!isSubmitting) {
// Check if we just finished submitting (transition from true to false)
const justFinishedSubmitting = prevIsSubmittingRef.current && !isSubmitting;
prevIsSubmittingRef.current = isSubmitting;
// Only process during submission OR when just finished
if (!isSubmitting && !justFinishedSubmitting) {
return;
}
if (orderedArtifactIds.length === 0) {
@@ -69,23 +79,15 @@ export default function useArtifacts() {
}
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
const latestArtifact = artifacts?.[latestArtifactId];
if (latestArtifact?.content === lastContentRef.current) {
if (latestArtifact?.content === lastContentRef.current && !justFinishedSubmitting) {
return;
}
setCurrentArtifactId(latestArtifactId);
lastContentRef.current = latestArtifact?.content ?? null;
const hasEnclosedArtifact =
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
latestMessageText.trim(),
);
if (hasEnclosedArtifact && !hasEnclosedArtifactRef.current) {
setActiveTab('preview');
hasEnclosedArtifactRef.current = true;
hasAutoSwitchedToCodeRef.current = false;
} else if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
// Only switch to code tab if we haven't detected an enclosed artifact yet
if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? '';
if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) {
setActiveTab('code');
@@ -101,6 +103,28 @@ export default function useArtifacts() {
setCurrentArtifactId,
]);
/**
* Watch for enclosed artifact pattern during message generation
* Optimized: Exits early if already detected, only checks during streaming
*/
useEffect(() => {
if (!isSubmitting || hasEnclosedArtifactRef.current) {
return;
}
const hasEnclosedArtifact =
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
latestMessageText.trim(),
);
if (hasEnclosedArtifact) {
logger.log('artifacts', 'Enclosed artifact detected during generation, switching to preview');
setActiveTab('preview');
hasEnclosedArtifactRef.current = true;
hasAutoSwitchedToCodeRef.current = false;
}
}, [isSubmitting, latestMessageText]);
useEffect(() => {
if (latestMessageId !== lastRunMessageIdRef.current) {
lastRunMessageIdRef.current = latestMessageId;
@@ -112,22 +136,13 @@ export default function useArtifacts() {
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
const currentIndex = orderedArtifactIds.indexOf(currentArtifactId ?? '');
const cycleArtifact = (direction: 'next' | 'prev') => {
let newIndex: number;
if (direction === 'next') {
newIndex = (currentIndex + 1) % orderedArtifactIds.length;
} else {
newIndex = (currentIndex - 1 + orderedArtifactIds.length) % orderedArtifactIds.length;
}
setCurrentArtifactId(orderedArtifactIds[newIndex]);
};
return {
activeTab,
setActiveTab,
currentIndex,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
setCurrentArtifactId,
};
}

View File

@@ -6,6 +6,7 @@ import {
QueryKeys,
ContentTypes,
EModelEndpoint,
getEndpointField,
isAgentsEndpoint,
parseCompactConvo,
replaceSpecialVars,
@@ -25,10 +26,10 @@ import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import store, { useGetEphemeralAgent } from '~/store';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey';
import { useNavigate } from 'react-router-dom';
import { useAuthContext } from '~/hooks';
import { logger } from '~/utils';
const logChatRequest = (request: Record<string, unknown>) => {
logger.log('=====================================\nAsk function called with:');

View File

@@ -1,18 +1,18 @@
import { useRecoilValue } from 'recoil';
import { useCallback, useRef, useEffect } from 'react';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import { LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider';
import { getEndpointField, LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider';
import type {
TPreset,
TModelsConfig,
TConversation,
TEndpointsConfig,
EModelEndpoint,
TModelsConfig,
TConversation,
TPreset,
} from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { AssistantListItem } from '~/common';
import { getEndpointField, buildDefaultConvo, getDefaultEndpoint, logger } from '~/utils';
import type { SetterOrUpdater } from 'recoil';
import useAssistantListMap from '~/hooks/Assistants/useAssistantListMap';
import { buildDefaultConvo, getDefaultEndpoint, logger } from '~/utils';
import { useGetEndpointsQuery } from '~/data-provider';
import { mainTextareaId } from '~/common';
import store from '~/store';

View File

@@ -2,20 +2,14 @@ import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants, dataService } from 'librechat-data-provider';
import { QueryKeys, Constants, dataService, getEndpointField } from 'librechat-data-provider';
import type {
TEndpointsConfig,
TStartupConfig,
TModelsConfig,
TConversation,
} from 'librechat-data-provider';
import {
getDefaultEndpoint,
clearMessagesCache,
buildDefaultConvo,
getEndpointField,
logger,
} from '~/utils';
import { getDefaultEndpoint, clearMessagesCache, buildDefaultConvo, logger } from '~/utils';
import { useApplyModelSpecEffects } from '~/hooks/Agents';
import store from '~/store';

View File

@@ -12,6 +12,7 @@ const knownEndpointAssets = {
[KnownEndpoints.fireworks]: 'assets/fireworks.png',
[KnownEndpoints.google]: 'assets/google.svg',
[KnownEndpoints.groq]: 'assets/groq.png',
[KnownEndpoints.helicone]: 'assets/helicone.png',
[KnownEndpoints.huggingface]: 'assets/huggingface.svg',
[KnownEndpoints.mistral]: 'assets/mistral.png',
[KnownEndpoints.mlx]: 'assets/mlx.png',

View File

@@ -5,6 +5,7 @@ import {
alternateName,
EModelEndpoint,
PermissionTypes,
getEndpointField,
} from 'librechat-data-provider';
import type {
TEndpointsConfig,
@@ -14,8 +15,8 @@ import type {
Agent,
} from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { mapEndpoints, getIconKey, getEndpointField } from '~/utils';
import { useGetEndpointsQuery } from '~/data-provider';
import { mapEndpoints, getIconKey } from '~/utils';
import { useHasAccess } from '~/hooks';
import { icons } from './Icons';

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useCallback, useRef } from 'react';
import { useDrop } from 'react-dnd';
import { useToastContext } from '@librechat/client';
import { NativeTypes } from 'react-dnd-html5-backend';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilValue, useSetRecoilState } from 'recoil';
@@ -7,10 +8,12 @@ import {
Tools,
QueryKeys,
Constants,
EModelEndpoint,
EToolResources,
EModelEndpoint,
mergeFileConfig,
AgentCapabilities,
isAssistantsEndpoint,
getEndpointFileConfig,
defaultAgentCapabilities,
} from 'librechat-data-provider';
import type { DropTargetMonitor } from 'react-dnd';
@@ -18,9 +21,12 @@ import type * as t from 'librechat-data-provider';
import store, { ephemeralAgentByConvoId } from '~/store';
import useFileHandling from './useFileHandling';
import { isEphemeralAgent } from '~/common';
import useLocalize from '../useLocalize';
export default function useDragHelpers() {
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const localize = useLocalize();
const [showModal, setShowModal] = useState(false);
const [draggedFiles, setDraggedFiles] = useState<File[]>([]);
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
@@ -33,9 +39,7 @@ export default function useDragHelpers() {
[conversation?.endpoint],
);
const { handleFiles } = useFileHandling({
overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents,
});
const { handleFiles } = useFileHandling();
const handleOptionSelect = useCallback(
(toolResource: EToolResources | undefined) => {
@@ -62,6 +66,26 @@ export default function useDragHelpers() {
const handleDrop = useCallback(
(item: { files: File[] }) => {
/** Early block: leverage endpoint file config to prevent drag/drop on disabled endpoints */
const currentEndpoint = conversationRef.current?.endpoint ?? 'default';
const currentEndpointType = conversationRef.current?.endpointType ?? undefined;
const cfg = queryClient.getQueryData<t.FileConfig>([QueryKeys.fileConfig]);
if (cfg) {
const mergedCfg = mergeFileConfig(cfg);
const endpointCfg = getEndpointFileConfig({
fileConfig: mergedCfg,
endpoint: currentEndpoint,
endpointType: currentEndpointType,
});
if (endpointCfg?.disabled === true) {
showToast({
message: localize('com_ui_attach_error_disabled'),
status: 'error',
});
return;
}
}
if (isAssistants) {
handleFilesRef.current(item.files);
return;
@@ -110,7 +134,7 @@ export default function useDragHelpers() {
setDraggedFiles(item.files);
setShowModal(true);
},
[isAssistants, queryClient],
[isAssistants, queryClient, showToast, localize],
);
const [{ canDrop, isOver }, drop] = useDrop(

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react';
import { v4 } from 'uuid';
import { useSetRecoilState } from 'recoil';
import { useToastContext } from '@librechat/client';
@@ -6,16 +6,14 @@ import { useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
Constants,
EModelEndpoint,
EToolResources,
mergeFileConfig,
isAgentsEndpoint,
isAssistantsEndpoint,
getEndpointFileConfig,
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import debounce from 'lodash/debounce';
import type { EndpointFileConfig, TEndpointsConfig, TError } from 'librechat-data-provider';
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
@@ -29,9 +27,7 @@ import useUpdateFiles from './useUpdateFiles';
type UseFileHandling = {
fileSetter?: FileSetter;
overrideEndpoint?: EModelEndpoint;
fileFilter?: (file: File) => boolean;
overrideEndpointFileConfig?: EndpointFileConfig;
additionalMetadata?: Record<string, string | undefined>;
};
@@ -54,17 +50,13 @@ const useFileHandling = (params?: UseFileHandling) => {
const agent_id = params?.additionalMetadata?.agent_id ?? '';
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
const endpointType = useMemo(() => conversation?.endpointType, [conversation?.endpointType]);
const endpoint = useMemo(() => conversation?.endpoint ?? 'default', [conversation?.endpoint]);
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpoint = useMemo(
() =>
params?.overrideEndpoint ?? conversation?.endpointType ?? conversation?.endpoint ?? 'default',
[params?.overrideEndpoint, conversation?.endpointType, conversation?.endpoint],
);
const displayToast = useCallback(() => {
if (errors.length > 1) {
// TODO: this should not be a dynamic localize input!!
@@ -169,10 +161,7 @@ const useFileHandling = (params?: UseFileHandling) => {
const formData = new FormData();
formData.append('endpoint', endpoint);
formData.append(
'original_endpoint',
conversation?.endpointType || conversation?.endpoint || '',
);
formData.append('endpointType', endpointType ?? '');
formData.append('file', extendedFile.file as File, encodeURIComponent(filename));
formData.append('file_id', extendedFile.file_id);
@@ -194,7 +183,7 @@ const useFileHandling = (params?: UseFileHandling) => {
}
}
if (isAgentsEndpoint(endpoint)) {
if (!isAssistantsEndpoint(endpointType ?? endpoint)) {
if (!agent_id) {
formData.append('message_file', 'true');
}
@@ -205,9 +194,7 @@ const useFileHandling = (params?: UseFileHandling) => {
if (conversation?.agent_id != null && formData.get('agent_id') == null) {
formData.append('agent_id', conversation.agent_id);
}
}
if (!isAssistantsEndpoint(endpoint)) {
uploadFile.mutate(formData);
return;
}
@@ -264,18 +251,19 @@ const useFileHandling = (params?: UseFileHandling) => {
/* Validate files */
let filesAreValid: boolean;
try {
const endpointFileConfig = getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
});
filesAreValid = validateFiles({
files,
fileList,
setError,
endpointFileConfig:
params?.overrideEndpointFileConfig ??
fileConfig?.endpoints?.[endpoint] ??
fileConfig?.endpoints?.default ??
defaultFileConfig.endpoints[endpoint] ??
defaultFileConfig.endpoints.default,
fileConfig,
endpointFileConfig,
toolResource: _toolResource,
fileConfig: fileConfig,
});
} catch (error) {
console.error('file validation error', error);

View File

@@ -5,11 +5,9 @@ import type { SharePointFile } from '~/data-provider/Files/sharepoint';
interface UseSharePointFileHandlingProps {
fileSetter?: any;
toolResource?: string;
fileFilter?: (file: File) => boolean;
additionalMetadata?: Record<string, string | undefined>;
overrideEndpoint?: any;
overrideEndpointFileConfig?: any;
toolResource?: string;
}
interface UseSharePointFileHandlingReturn {

View File

@@ -1,6 +1,6 @@
import { getEndpointField } from 'librechat-data-provider';
import { useChatContext } from '~/Providers/ChatContext';
import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField } from '~/utils';
import useUserKey from './useUserKey';
export default function useRequiresKey() {

View File

@@ -99,6 +99,12 @@ export default function useStepHandler({
if (!updatedContent[index]) {
updatedContent[index] = { type: contentPart.type as AllContentTypes };
}
/** Prevent overwriting an existing content part with a different type */
const existingType = (updatedContent[index]?.type as string | undefined) ?? '';
if (existingType && !contentType.startsWith(existingType)) {
console.warn('Content type mismatch');
return message;
}
if (
contentType.startsWith(ContentTypes.TEXT) &&
@@ -151,12 +157,16 @@ export default function useStepHandler({
const existingToolCall = existingContent?.tool_call;
const toolCallArgs = (contentPart.tool_call as Agents.ToolCall).args;
/** When args are a valid object, they are likely already invoked */
const args =
let args =
finalUpdate ||
typeof existingToolCall?.args === 'object' ||
typeof toolCallArgs === 'object'
? contentPart.tool_call.args
: (existingToolCall?.args ?? '') + (toolCallArgs ?? '');
/** Preserve previously streamed args when final update omits them */
if (finalUpdate && args == null && existingToolCall?.args != null) {
args = existingToolCall.args;
}
const id = getNonEmptyValue([contentPart.tool_call.id, existingToolCall?.id]) ?? '';
const name = getNonEmptyValue([contentPart.tool_call.name, existingToolCall?.name]) ?? '';

View File

@@ -1,15 +1,16 @@
import { useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import { useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil';
import {
Constants,
FileSources,
EModelEndpoint,
isParamEndpoint,
getEndpointField,
LocalStorageKeys,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import { useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil';
import type {
TPreset,
TSubmission,
@@ -19,19 +20,18 @@ import type {
} from 'librechat-data-provider';
import type { AssistantListItem } from '~/common';
import {
getEndpointField,
buildDefaultConvo,
updateLastSelectedModel,
getDefaultModelSpec,
getDefaultEndpoint,
getModelSpecPreset,
getDefaultModelSpec,
updateLastSelectedModel,
buildDefaultConvo,
logger,
} from '~/utils';
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import useAssistantListMap from './Assistants/useAssistantListMap';
import { useResetChatBadges } from './useChatBadges';
import { useApplyModelSpecEffects } from './Agents';
import { usePauseGlobalAudio } from './Audio';
import { logger } from '~/utils';
import store from '~/store';
const useNewConvo = (index = 0) => {

View File

@@ -1,6 +1,6 @@
{
"chat_direction_left_to_right": "Leer etwas fehlt noch",
"chat_direction_right_to_left": "Leer etwas fehlt noch",
"chat_direction_left_to_right": "Links nach Rechts",
"chat_direction_right_to_left": "Rechts nach Links",
"com_a11y_ai_composing": "Die KI erstellt noch ihre Antwort.\n",
"com_a11y_end": "Die KI hat die Antwort fertiggestellt.",
"com_a11y_start": "Die KI hat mit ihrer Antwort begonnen. ",
@@ -365,6 +365,7 @@
"com_error_files_process": "Bei der Verarbeitung der Datei ist ein Fehler aufgetreten.",
"com_error_files_upload": "Beim Hochladen der Datei ist ein Fehler aufgetreten",
"com_error_files_upload_canceled": "Die Datei-Upload-Anfrage wurde abgebrochen. Hinweis: Der Upload-Vorgang könnte noch im Hintergrund laufen und die Datei muss möglicherweise manuell gelöscht werden.",
"com_error_files_upload_too_large": "Die Datei ist zu groß. Bitte lade eine Datei hoch, die kleiner als {{0}} MB ist.",
"com_error_files_validation": "Bei der Validierung der Datei ist ein Fehler aufgetreten.",
"com_error_google_tool_conflict": "Die integrierten Google-Tools können nicht zusammen mit externen Tools verwendet werden. Bitte deaktiviere entweder die integrierten oder die externen Tools.",
"com_error_heic_conversion": "Das HEIC-Bild konnte nicht in JPEG konvertiert werden. Bitte versuchen Sie, das Bild manuell zu konvertieren oder verwenden Sie ein anderes Format.",
@@ -385,6 +386,7 @@
"com_files_download_progress": "{{0}} von {{1}} Dateien",
"com_files_downloading": "Dateien werden heruntergeladen",
"com_files_filter": "Dateien filtern...",
"com_files_filter_by": "Dateien filtern nach...",
"com_files_no_results": "Keine Ergebnisse.",
"com_files_number_selected": "{{0}} von {{1}} Datei(en) ausgewählt",
"com_files_preparing_download": "Download wird vorbereitet...",
@@ -558,6 +560,7 @@
"com_nav_setting_balance": "Saldo",
"com_nav_setting_chat": "Chat",
"com_nav_setting_data": "Datensteuerung",
"com_nav_setting_delay": "Verzögerung (s)",
"com_nav_setting_general": "Allgemein",
"com_nav_setting_mcp": "MCP Einstellungen",
"com_nav_setting_personalization": "Personalisierung",
@@ -628,6 +631,7 @@
"com_ui_action_button": "Aktions Button",
"com_ui_active": "Aktiv",
"com_ui_add": "Hinzufügen",
"com_ui_add_api_key": "API-Schlüssel hinzufügen",
"com_ui_add_mcp": "MCP hinzufügen",
"com_ui_add_mcp_server": "MCP Server hinzufügen",
"com_ui_add_model_preset": "Ein KI-Modell oder eine Voreinstellung für eine zusätzliche Antwort hinzufügen",
@@ -654,6 +658,16 @@
"com_ui_agent_deleted": "Agent erfolgreich gelöscht",
"com_ui_agent_duplicate_error": "Beim Duplizieren des Assistenten ist ein Fehler aufgetreten",
"com_ui_agent_duplicated": "Agent wurde erfolgreich dupliziert",
"com_ui_agent_handoff_add": "Übergabe-Agent hinzufügen",
"com_ui_agent_handoff_description": "Übergabebeschreibung",
"com_ui_agent_handoff_description_placeholder": "z. B. Übergabe an Marketing-Texter für Blogartikel-Entwurf",
"com_ui_agent_handoff_info": "Konfiguriere Agenten, an die dieser Agent Konversationen übergeben kann, wenn spezifisches Fachwissen benötigt wird.",
"com_ui_agent_handoff_info_2": "Jede Übergabe erstellt ein Transfer-Tool, das eine nahtlose Weiterleitung an spezialisierte Agenten mit Kontext ermöglicht.",
"com_ui_agent_handoff_max": "Maximale Anzahl ({{0}}) an Übergabe-Agenten erreicht.",
"com_ui_agent_handoff_prompt": "Inhalt zur Übergabe",
"com_ui_agent_handoff_prompt_key": "Parametername für Inhalt (Standard: 'instructions') ",
"com_ui_agent_handoff_prompt_placeholder": "Sag diesem Agenten, welchen Inhalt er generieren und an den Übergabe-Agenten weiterleiten soll. Du musst hier etwas eingeben, um diese Funktion zu aktivieren.",
"com_ui_agent_handoffs": "Agenten-Übergaben",
"com_ui_agent_name_is_required": "Ein Agentenname ist erforderlich.",
"com_ui_agent_recursion_limit": "Maximale Agenten-Schritte",
"com_ui_agent_recursion_limit_info": "Begrenzt, wie viele Schritte der Agent in einem Durchlauf ausführen kann, bevor er eine endgültige Antwort gibt. Der Standardwert ist 25 Schritte. Ein Schritt ist entweder eine KI-API-Anfrage oder eine Werkzeugnutzungsrunde. Eine einfache Werkzeuginteraktion umfasst beispielsweise 3 Schritte: die ursprüngliche Anfrage, die Werkzeugnutzung und die Folgeanfrage.",
@@ -725,6 +739,7 @@
"com_ui_basic": "Basic",
"com_ui_basic_auth_header": "Basic-Authentifizierungsheader",
"com_ui_bearer": "Bearer",
"com_ui_beta": "Beta",
"com_ui_bookmark_delete_confirm": "Bist du sicher, dass du dieses Lesezeichen löschen möchtest?",
"com_ui_bookmarks": "Lesezeichen",
"com_ui_bookmarks_add": "Lesezeichen hinzufügen",
@@ -757,6 +772,7 @@
"com_ui_client_secret": "Client Secret",
"com_ui_close": "Schließen",
"com_ui_close_menu": "Menü schließen",
"com_ui_close_settings": "Einstellungen schließen",
"com_ui_close_window": "Fenster schliessen",
"com_ui_code": "Code",
"com_ui_collapse_chat": "Chat einklappen",
@@ -779,6 +795,8 @@
"com_ui_copied_to_clipboard": "In die Zwischenablage kopiert",
"com_ui_copy_code": "Code kopieren",
"com_ui_copy_link": "Link kopieren",
"com_ui_copy_stack_trace": "Stack-Trace kopieren",
"com_ui_copy_thoughts_to_clipboard": "Gedanken in die Zwischenablage kopieren",
"com_ui_copy_to_clipboard": "In die Zwischenablage kopieren",
"com_ui_copy_url_to_clipboard": "URL in die Zwischenablage kopieren",
"com_ui_create": "Erstellen",
@@ -844,6 +862,7 @@
"com_ui_download_backup": "Backup-Codes herunterladen",
"com_ui_download_backup_tooltip": "Bevor Sie fortfahren, laden Sie bitte Ihre Backup-Codes herunter. Sie benötigen sie, um den Zugang wiederherzustellen, falls Sie Ihr Authentifizierungsgerät verlieren.",
"com_ui_download_error": "Fehler beim Herunterladen der Datei. Die Datei wurde möglicherweise gelöscht.",
"com_ui_download_error_logs": "Fehlerprotokolle herunterladen",
"com_ui_drag_drop": "Ziehe eine beliebige Datei hierher, um sie zur Unterhaltung hinzuzufügen.",
"com_ui_dropdown_variables": "Dropdown-Variablen:",
"com_ui_dropdown_variables_info": "Erstelle benutzerdefinierte Dropdown-Menüs für deine Prompts: `{{variable_name:option1|option2|option3}}`",
@@ -855,6 +874,8 @@
"com_ui_edit_editing_image": "Bild bearbeiten\n",
"com_ui_edit_mcp_server": "MCP-Server bearbeiten",
"com_ui_edit_memory": "Erinnerung bearbeiten",
"com_ui_editable_message": "Bearbeitbare Nachricht",
"com_ui_editor_instructions": "Zieh das Bild, um es neu zu positionieren • Nutze den Zoom-Regler oder die Schaltflächen zur Größenanpassung.",
"com_ui_empty_category": "-",
"com_ui_endpoint": "Endpunkt",
"com_ui_endpoint_menu": "LLM-Endpunkt-Menü",
@@ -889,6 +910,7 @@
"com_ui_feedback_tag_unjustified_refusal": "Mit anderer Begründung abgelehnt",
"com_ui_field_max_length": "{{field}} darf maximal {{length}} Zeichen haben",
"com_ui_field_required": "Dieses Feld ist erforderlich",
"com_ui_file_input_avatar_label": "Dateiauswahl für Avatar",
"com_ui_file_size": "Dateigröße",
"com_ui_file_token_limit": "Datei-Token-Limit",
"com_ui_file_token_limit_desc": "Lege ein maximales Token-Limit für die Dateiverarbeitung fest, um Kosten und Ressourcenverbrauch zu steuern.",
@@ -934,6 +956,7 @@
"com_ui_good_evening": "Guten Abend",
"com_ui_good_morning": "Guten Morgen",
"com_ui_group": "Gruppe",
"com_ui_handoff_instructions": "Übergabebeschreibung",
"com_ui_happy_birthday": "Es ist mein 1. Geburtstag!",
"com_ui_hide_image_details": "Details zum Bild ausblenden",
"com_ui_hide_password": "Passwort verbergen",
@@ -951,11 +974,13 @@
"com_ui_import_conversation_file_type_error": "Nicht unterstützter Importtyp",
"com_ui_import_conversation_info": "Konversationen aus einer JSON-Datei importieren",
"com_ui_import_conversation_success": "Konversationen erfolgreich importiert",
"com_ui_import_conversation_upload_error": "Fehler beim Hochladen der Datei. Bitte versuch es erneut.",
"com_ui_include_shadcnui": "Anweisungen für shadcn/ui-Komponenten einschließen",
"com_ui_initializing": "Initialisiere...",
"com_ui_input": "Eingabe",
"com_ui_instructions": "Anweisungen",
"com_ui_key": "Schlüssel",
"com_ui_key_required": "API-Schlüssel ist erforderlich",
"com_ui_late_night": "Schöne späte Nacht",
"com_ui_latest_footer": "Alle KIs für alle.",
"com_ui_latest_production_version": "Neueste Produktiv-Version",
@@ -970,6 +995,7 @@
"com_ui_manage": "Verwalten",
"com_ui_marketplace": "Marktplatz",
"com_ui_marketplace_allow_use": "Nutzung des Marktplatzes erlauben",
"com_ui_max_file_size": "PNG, JPG oder JPEG (max. {{0}})",
"com_ui_max_tags": "Die maximale Anzahl ist {{0}}, es werden die neuesten Werte verwendet.",
"com_ui_mcp_authenticated_success": "MCP-Server „{{0}}“ erfolgreich authentifiziert.",
"com_ui_mcp_configure_server": "Konfiguriere {{0}}",
@@ -1005,6 +1031,7 @@
"com_ui_memory_updated_items": "Aktualisierte Erinnerungen",
"com_ui_memory_would_exceed": "Speichern nicht möglich - würde Limit um {{tokens}} Tokens überschreiten. Löschen Sie vorhandene Erinnerungen, um Platz zu schaffen.",
"com_ui_mention": "Erwähne einen Endpunkt, Assistenten oder eine Voreinstellung, um schnell dorthin zu wechseln",
"com_ui_message_input": "Nachrichteneingabe",
"com_ui_min_tags": "Es können nicht mehr Werte entfernt werden, mindestens {{0}} sind erforderlich.",
"com_ui_minimal": "Minimal",
"com_ui_misc": "Verschiedenes",
@@ -1064,6 +1091,8 @@
"com_ui_privacy_policy": "Datenschutzerklärung",
"com_ui_privacy_policy_url": "Datenschutzrichtlinie-URL",
"com_ui_prompt": "Prompt",
"com_ui_prompt_input": "Prompt-Eingabe",
"com_ui_prompt_input_field": "Prompt-Texteingabefeld",
"com_ui_prompt_name": "Prompt-Name",
"com_ui_prompt_name_required": "Prompt-Name ist erforderlich",
"com_ui_prompt_preview_not_shared": "Der Autor hat die Zusammenarbeit für diesen Prompt nicht erlaubt.",
@@ -1082,25 +1111,32 @@
"com_ui_reference_saved_memories_description": "Erlaube der KI bei den Antworten auf deine gespeicherten Erinnerungen zuzugreifen und sie zu verwenden.",
"com_ui_refresh": "Aktualisieren",
"com_ui_refresh_link": "Link aktualisieren",
"com_ui_refresh_page": "Seite aktualisieren",
"com_ui_regenerate": "Neu generieren",
"com_ui_regenerate_backup": "Backup-Codes neu generieren",
"com_ui_regenerating": "Generiere neu ...",
"com_ui_region": "Region",
"com_ui_reinitialize": "Neu initialisieren",
"com_ui_remove_agent_from_chain": "{{0}} aus der Kette entfernen",
"com_ui_remove_user": "{{0}} entfernen",
"com_ui_rename": "Umbenennen",
"com_ui_rename_conversation": "Chat umbenennen",
"com_ui_rename_failed": "Chat konnte nicht umbenannt werden.",
"com_ui_rename_prompt": "Prompt umbenennen",
"com_ui_requires_auth": "Authentifizierung erforderlich",
"com_ui_reset": "Zurücksetzen",
"com_ui_reset_adjustments": "Anpassungen zurücksetzen",
"com_ui_reset_var": "{{0}} zurücksetzen",
"com_ui_reset_zoom": "Zoom zurücksetzen",
"com_ui_resource": "Ressource",
"com_ui_response": "Antwort",
"com_ui_result": "Ergebnis",
"com_ui_revoke": "Widerrufen",
"com_ui_revoke_info": "Benutzer-API-Keys widerrufen",
"com_ui_revoke_key_confirm": "Bist du sicher, dass du diesen Schlüssel widerrufen möchtest?",
"com_ui_revoke_key_endpoint": "API-Schlüssel für {{0}} widerrufen",
"com_ui_revoke_key_error": "API-Schlüssel konnte nicht widerrufen werden. Bitte versuch es erneut.",
"com_ui_revoke_key_success": "API-Schlüssel erfolgreich widerrufen",
"com_ui_revoke_keys": "Schlüssel widerrufen",
"com_ui_revoke_keys_confirm": "Bist du sicher, dass du alle Schlüssel widerrufen möchtest?",
"com_ui_role": "Rolle",
@@ -1114,11 +1150,15 @@
"com_ui_role_viewer": "Betrachter",
"com_ui_role_viewer_desc": "Kann den Agenten ansehen und nutzen aber nicht bearbeiten",
"com_ui_roleplay": "Rollenspiel",
"com_ui_rotate": "Drehen",
"com_ui_rotate_90": "Um 90 Grad drehen",
"com_ui_run_code": "Code ausführen",
"com_ui_run_code_error": "Bei der Ausführung des Codes ist ein Fehler aufgetreten",
"com_ui_save": "Speichern",
"com_ui_save_badge_changes": "Änderungen an Badges speichern?",
"com_ui_save_changes": "Änderungen speichern",
"com_ui_save_key_error": "API-Schlüssel konnte nicht gespeichert werden. Bitte versuch es erneut.",
"com_ui_save_key_success": "API-Schlüssel erfolgreich gespeichert",
"com_ui_save_submit": "Speichern & Absenden",
"com_ui_saved": "Gespeichert!",
"com_ui_saving": "Sicherung läuft...",
@@ -1154,6 +1194,7 @@
"com_ui_share_everyone": "Mit allen teilen",
"com_ui_share_everyone_description_var": "{{resource}} wird für alle verfügbar sein. Bitte stelle sicher, dass {{resource}} wirklich für alle freigegeben werden soll. Sei vorsichtig mit deinen Daten.",
"com_ui_share_link_to_chat": "Link zum Chat teilen",
"com_ui_share_qr_code_description": "QR-Code zum Teilen dieses Konversationslinks",
"com_ui_share_update_message": "Ihr Name, benutzerdefinierte Anweisungen und alle Nachrichten, die du nach dem Teilen hinzufügen, bleiben privat.",
"com_ui_share_var": "{{0}} teilen",
"com_ui_shared_link_bulk_delete_success": "Geteilte Links erfolgreich gelöscht",
@@ -1200,6 +1241,7 @@
"com_ui_tool_info": "Tool Information",
"com_ui_tool_more_info": "Mehr Information über dieses Tool",
"com_ui_tools": "Werkzeuge",
"com_ui_transferred_to": "Übergeben an",
"com_ui_travel": "Reisen",
"com_ui_trust_app": "Ich vertraue dieser Anwendung",
"com_ui_try_adjusting_search": "Versuche, deine Suchbegriffe anzupassen",
@@ -1215,6 +1257,8 @@
"com_ui_update_mcp_success": "MCP erfolgreich erstellt oder aktualisiert",
"com_ui_upload": "Hochladen",
"com_ui_upload_agent_avatar": "Agenten-Avatar erfolgreich aktualisiert",
"com_ui_upload_agent_avatar_label": "Avatarbild des Agenten hochladen",
"com_ui_upload_avatar_label": "Avatarbild hochladen",
"com_ui_upload_code_files": "Hochladen für Code-Interpreter",
"com_ui_upload_delay": "Das Hochladen von \"{{0}}\" dauert etwas länger. Bitte warte, während die Datei für den Abruf indexiert wird.",
"com_ui_upload_error": "Beim Hochladen Ihrer Datei ist ein Fehler aufgetreten",
@@ -1225,7 +1269,7 @@
"com_ui_upload_image_input": "Bild hochladen",
"com_ui_upload_invalid": "Ungültige Datei zum Hochladen. Muss ein Bild sein und das Limit nicht überschreiten",
"com_ui_upload_invalid_var": "Ungültige Datei zum Hochladen. Muss ein Bild sein und {{0}} MB nicht überschreiten",
"com_ui_upload_ocr_text": "Hochladen als Text mit OCR",
"com_ui_upload_ocr_text": "Hochladen als Text",
"com_ui_upload_provider": "Hochladen zum KI-Anbieter",
"com_ui_upload_success": "Datei erfolgreich hochgeladen",
"com_ui_upload_type": "Upload-Typ auswählen",
@@ -1276,5 +1320,8 @@
"com_ui_x_selected": "{{0}} ausgewählt",
"com_ui_yes": "Ja",
"com_ui_zoom": "Zoom",
"com_ui_zoom_in": "Heranzoomen",
"com_ui_zoom_level": "Zoomstufe",
"com_ui_zoom_out": "Herauszoomen",
"com_user_message": "Du"
}

View File

@@ -378,6 +378,7 @@
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
"com_error_no_user_key": "No key found. Please provide a key and try again.",
"com_error_refusal": "Response refused by safety filters. Rewrite your message and try again. If you encounter this frequently while using Claude Sonnet 4.5 or Opus 4.1, you can try Sonnet 4, which has different usage restrictions.",
"com_file_pages": "Pages: {{pages}}",
"com_file_source": "File",
"com_file_unknown": "Unknown File",
@@ -713,6 +714,7 @@
"com_ui_assistants_output": "Assistants Output",
"com_ui_at_least_one_owner_required": "At least one owner is required",
"com_ui_attach_error": "Cannot attach file. Create or select a conversation, or try refreshing the page.",
"com_ui_attach_error_disabled": "File uploads are disabled for this endpoint",
"com_ui_attach_error_openai": "Cannot attach Assistant files to other endpoints",
"com_ui_attach_error_size": "File size limit exceeded for endpoint:",
"com_ui_attach_error_type": "Unsupported file type for endpoint:",
@@ -767,10 +769,12 @@
"com_ui_cancel": "Cancel",
"com_ui_cancelled": "Cancelled",
"com_ui_category": "Category",
"com_ui_change_version": "Change Version",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
"com_ui_clear": "Clear",
"com_ui_clear_all": "Clear all",
"com_ui_click_to_close": "Click to close",
"com_ui_client_id": "Client ID",
"com_ui_client_secret": "Client Secret",
"com_ui_close": "Close",

View File

@@ -378,6 +378,7 @@
"com_error_moderation": "Šķiet, ka mūsu moderācijas sistēma ir atzīmējusi nosūtīto saturu kā neatbilstošu mūsu vadlīnijām. Mēs nevaram turpināt darbu ar šo konkrēto tēmu. Ja jums ir vēl kādi jautājumi vai tēmas, kuras vēlaties izpētīt, lūdzu, rediģējiet savu ziņu vai izveidojiet jaunu sarunu.",
"com_error_no_base_url": "Nav atrasts bāzes URL. Lūdzu, norādiet to un mēģiniet vēlreiz.",
"com_error_no_user_key": "Atslēga nav atrasta. Lūdzu, norādiet atslēgu un mēģiniet vēlreiz.",
"com_error_refusal": "Drošības filtri noraidīja atbildi. Pārrakstiet savu ziņojumu un mēģiniet vēlreiz. Ja, lietojot Claude Sonnet 4.5 vai Opus 4.1, ar šo problēmu bieži saskaraties, varat izmēģināt Sonnet 4, kuram ir atšķirīgi lietošanas ierobežojumi.",
"com_file_pages": "Lapas: {{pages}}",
"com_file_source": "Fails",
"com_file_unknown": "Nezināms fails",
@@ -594,7 +595,7 @@
"com_show_examples": "Rādīt piemērus",
"com_sidepanel_agent_builder": "Aģentu veidotājs",
"com_sidepanel_assistant_builder": "Asistenta veidotāju",
"com_sidepanel_attach_files": "Pievienot failus",
"com_sidepanel_attach_files": "Failu Pārvaldība",
"com_sidepanel_conversation_tags": "Grāmatzīmes",
"com_sidepanel_hide_panel": "Slēpt paneli",
"com_sidepanel_manage_files": "Pārvaldīt failus",
@@ -659,6 +660,17 @@
"com_ui_agent_deleted": "Aģents veiksmīgi dzēsts",
"com_ui_agent_duplicate_error": "Dublējot aģentu, radās kļūda.",
"com_ui_agent_duplicated": "Aģents veiksmīgi dublēts",
"com_ui_agent_handoff_add": "Pievienot nodošanas aģentu",
"com_ui_agent_handoff_description": "Nodošanas apraksts",
"com_ui_agent_handoff_description_placeholder": "piem., pārsūtīšana datu analītiķim statistiskai analīzei",
"com_ui_agent_handoff_info": "Konfigurēt aģentus, kuriem šis aģents var pāradresēt sarunas, ja nepieciešama speciālizēta informācija.",
"com_ui_agent_handoff_info_2": "Katrā nodošanas reizē tiek izveidots nodošanas rīks, kas nodrošina netraucētu maršrutēšanu pie specializētiem aģentiem, izmantojot kontekstu.",
"com_ui_agent_handoff_max": "Maksimālais daudzums nodošanas aģentu sasniegts {{0}}.",
"com_ui_agent_handoff_prompt": "Caurlaides saturs",
"com_ui_agent_handoff_prompt_key": "Satura parametra nosaukums (noklusējums: \"instrukcijas\")",
"com_ui_agent_handoff_prompt_key_placeholder": "Apzīmējiet nodoto saturu (noklusējums: \"instrukcijas\")",
"com_ui_agent_handoff_prompt_placeholder": "Norādiet šim aģentam, kādu saturu ģenerēt un nodot nodošanas aģentam. Lai iespējotu šo funkciju, šeit ir jāpievieno kaut kas.",
"com_ui_agent_handoffs": "Aģentu nodošanas",
"com_ui_agent_name_is_required": "Obligāti jānorāda aģenta nosaukums",
"com_ui_agent_recursion_limit": "Maksimālais aģenta soļu skaits",
"com_ui_agent_recursion_limit_info": "Ierobežo, cik soļus aģents var veikt vienā izpildes reizē, pirms sniedz galīgo atbildi. Noklusējuma vērtība ir 25 soļi. Solis ir vai nu AI API pieprasījums, vai rīka lietošanas kārta. Piemēram, pamata rīka mijiedarbība ietver 3 soļus: sākotnējo pieprasījumu, rīka lietošanu un turpmāko pieprasījumu.",
@@ -702,6 +714,7 @@
"com_ui_assistants_output": "Asistentu izvade",
"com_ui_at_least_one_owner_required": "Nepieciešams vismaz viens īpašnieks",
"com_ui_attach_error": "Nevar pievienot failu. Izveidojiet vai atlasiet sarunu vai mēģiniet atsvaidzināt lapu.",
"com_ui_attach_error_disabled": "Šim galapunktam failu augšupielāde ir atspējota.",
"com_ui_attach_error_openai": "Nevar pievienot asistenta failus citiem galapunktiem",
"com_ui_attach_error_size": "Galapunkta faila lieluma ierobežojums ir pārsniegts:",
"com_ui_attach_error_type": "Neatbalstīts faila tips galapunktam:",
@@ -731,6 +744,7 @@
"com_ui_basic": "Pamata",
"com_ui_basic_auth_header": "Pamata autorizācijas galvene",
"com_ui_bearer": "Nesējs",
"com_ui_beta": "Beta versija",
"com_ui_bookmark_delete_confirm": "Vai tiešām vēlaties dzēst šo grāmatzīmi?",
"com_ui_bookmarks": "Grāmatzīmes",
"com_ui_bookmarks_add": "Pievienot grāmatzīmi",
@@ -755,10 +769,12 @@
"com_ui_cancel": "Atcelt",
"com_ui_cancelled": "Atcelts",
"com_ui_category": "Kategorija",
"com_ui_change_version": "Mainīt versiju",
"com_ui_chat": "Saruna",
"com_ui_chat_history": "Sarunu vēsture",
"com_ui_clear": "Notīrīt",
"com_ui_clear_all": "Notīrīt visu",
"com_ui_click_to_close": "Noklikšķiniet, lai aizvērtu",
"com_ui_client_id": "Klienta ID",
"com_ui_client_secret": "Klienta noslēpums",
"com_ui_close": "Aizvērt",
@@ -949,6 +965,7 @@
"com_ui_good_evening": "Labvakar",
"com_ui_good_morning": "Labrīt",
"com_ui_group": "Grupa",
"com_ui_handoff_instructions": "Nodošanas instrukcijas",
"com_ui_happy_birthday": "Man šodien ir pirmā dzimšanas diena!",
"com_ui_hide_image_details": "Slēpt attēla detaļas",
"com_ui_hide_password": "Paslēpt paroli",
@@ -1234,6 +1251,7 @@
"com_ui_tool_info": "Informācija par rīku",
"com_ui_tool_more_info": "Vairāk informācijas par šo rīku",
"com_ui_tools": "Rīki",
"com_ui_transferred_to": "Pārcelts uz",
"com_ui_travel": "Ceļošana",
"com_ui_trust_app": "Es uzticos šai lietotnei",
"com_ui_try_adjusting_search": "Mēģiniet pielāgot meklēšanas vaicājumus",

View File

@@ -1,6 +1,6 @@
{
"chat_direction_left_to_right": "这里需要放点东西,当前是空的",
"chat_direction_right_to_left": "这里需要放点东西,当前是空的",
"chat_direction_left_to_right": "从左到右",
"chat_direction_right_to_left": "从右到左",
"com_a11y_ai_composing": "AI 仍在撰写中。",
"com_a11y_end": "AI 已完成回复。",
"com_a11y_start": "AI 已开始回复。",
@@ -261,10 +261,10 @@
"com_endpoint_context_info": "可用于上下文的最大词元数。用于控制每个请求发送的词元数量。如果未指定,将根据已知模型的上下文大小使用系统默认值。设置较高的值可能会导致错误和/或更高的词元成本。",
"com_endpoint_context_tokens": "最大上下文词元数",
"com_endpoint_custom_name": "自定义名称",
"com_endpoint_default": "初始值",
"com_endpoint_default_blank": "初始值:空白",
"com_endpoint_default_empty": "初始值:空",
"com_endpoint_default_with_num": "初始值:{{0}}",
"com_endpoint_default": "默认值",
"com_endpoint_default_blank": "默认值:空白",
"com_endpoint_default_empty": "默认值:空",
"com_endpoint_default_with_num": "默认值:{{0}}",
"com_endpoint_deprecated": "已弃用",
"com_endpoint_deprecated_info": "此端点已被弃用并可能在未来的版本中删除,请改用智能体端点",
"com_endpoint_deprecated_info_a11y": "此插件端点已被弃用并可能在未来的版本中删除,请改用智能体端点",
@@ -386,6 +386,7 @@
"com_files_download_progress": "第 {{0}} 个文件,共 {{1}} 个",
"com_files_downloading": "正在下载文件",
"com_files_filter": "筛选文件...",
"com_files_filter_by": "按以下条件筛选文件...",
"com_files_no_results": "无结果。",
"com_files_number_selected": "已选择 {{0}} 个文件(共 {{1}} 个文件)",
"com_files_preparing_download": "准备下载...",
@@ -631,9 +632,10 @@
"com_ui_action_button": "操作按钮",
"com_ui_active": "活动",
"com_ui_add": "添加",
"com_ui_add_api_key": "添加 API 密钥",
"com_ui_add_mcp": "添加 MCP",
"com_ui_add_mcp_server": "添加 MCP 服务器",
"com_ui_add_model_preset": "添加一个模型或预设以获得额外的回复",
"com_ui_add_model_preset": "添加一个模型或预设以获得额外的响应",
"com_ui_add_multi_conversation": "添加多个对话",
"com_ui_adding_details": "添加细节",
"com_ui_admin": "管理",
@@ -652,11 +654,22 @@
"com_ui_agent_category_selector_aria": "智能体类别选择器",
"com_ui_agent_chain": "智能体链Mixture-of-Agents",
"com_ui_agent_chain_info": "启用创建智能体序列。每个智能体都可以访问智能体链中先前智能体的输出。基于 “Mixture-of-Agents” 架构,智能体使用先前的输出作为辅助信息。",
"com_ui_agent_chain_max": "您已达到智能体 {{0}} 的最大值",
"com_ui_agent_chain_max": "您已达到 {{0}} 个智能体的最大值",
"com_ui_agent_delete_error": "删除智能体时出现错误",
"com_ui_agent_deleted": "智能体已成功删除",
"com_ui_agent_duplicate_error": "复制智能体时发生错误",
"com_ui_agent_duplicated": "智能体复制成功",
"com_ui_agent_handoff_add": "添加接力智能体",
"com_ui_agent_handoff_description": "接力说明",
"com_ui_agent_handoff_description_placeholder": "例如:转交给数据分析师进行统计分析",
"com_ui_agent_handoff_info": "配置接力智能体,以便在需要特定专业知识时,可以将对话转移至接力智能体。",
"com_ui_agent_handoff_info_2": "每次接力都会创建一个传输工具,将上下文无缝路由到专用智能体。",
"com_ui_agent_handoff_max": "以达到 {{0}} 个接力智能体的最大值",
"com_ui_agent_handoff_prompt": "传递内容",
"com_ui_agent_handoff_prompt_key": "内容参数名称(默认值:“指令”)",
"com_ui_agent_handoff_prompt_key_placeholder": "标记传递的内容(默认值:“指令”)",
"com_ui_agent_handoff_prompt_placeholder": "告诉智能体要生成什么内容,并将其传递给接力智能体。您需要在这里添加一些内容以启用此功能",
"com_ui_agent_handoffs": "智能体接力",
"com_ui_agent_name_is_required": "智能体名称为必填项",
"com_ui_agent_recursion_limit": "最大智能体步骤数",
"com_ui_agent_recursion_limit_info": "限制智能体在给出最终响应之前可执行的步骤数。默认为 25 个步骤。步骤可以是一次 AI API 请求或一次工具使用。例如,一个基本的工具交互需要 3 个步骤:初始请求、工具使用和后续请求。",
@@ -717,6 +730,7 @@
"com_ui_azure": "Azure",
"com_ui_azure_ad": "Entra ID",
"com_ui_back": "后退",
"com_ui_back_to_builder": "返回构建器",
"com_ui_back_to_chat": "返回对话",
"com_ui_back_to_prompts": "返回提示词",
"com_ui_backup_code_number": "代码 #{{number}}",
@@ -728,6 +742,7 @@
"com_ui_basic": "基本",
"com_ui_basic_auth_header": "Basic authorization header",
"com_ui_bearer": "Bearer",
"com_ui_beta": "测试",
"com_ui_bookmark_delete_confirm": "您确定要删除此书签吗?",
"com_ui_bookmarks": "书签",
"com_ui_bookmarks_add": "添加书签",
@@ -783,6 +798,8 @@
"com_ui_copied_to_clipboard": "已复制到剪贴板",
"com_ui_copy_code": "复制代码",
"com_ui_copy_link": "复制链接",
"com_ui_copy_stack_trace": "复制堆栈跟踪",
"com_ui_copy_thoughts_to_clipboard": "复制思考内容到剪贴板",
"com_ui_copy_to_clipboard": "复制到剪贴板",
"com_ui_copy_url_to_clipboard": "复制 URL 到剪贴板",
"com_ui_create": "创建",
@@ -848,6 +865,7 @@
"com_ui_download_backup": "下载备份代码",
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
"com_ui_download_error_logs": "下载错误日志",
"com_ui_drag_drop": "将任意文件拖放到此处以添加到对话中",
"com_ui_dropdown_variables": "下拉变量:",
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
@@ -859,6 +877,7 @@
"com_ui_edit_editing_image": "编辑图片",
"com_ui_edit_mcp_server": "编辑 MCP 服务器",
"com_ui_edit_memory": "编辑记忆",
"com_ui_editable_message": "可编辑的消息",
"com_ui_editor_instructions": "拖动图片调整位置 • 使用缩放滑块或按钮调整大小",
"com_ui_empty_category": "-",
"com_ui_endpoint": "端点",
@@ -940,6 +959,7 @@
"com_ui_good_evening": "晚上好",
"com_ui_good_morning": "早上好",
"com_ui_group": "群组",
"com_ui_handoff_instructions": "接力指令",
"com_ui_happy_birthday": "这是我的第一个生日!",
"com_ui_hide_image_details": "隐藏图片详情",
"com_ui_hide_password": "隐藏密码",
@@ -1014,6 +1034,7 @@
"com_ui_memory_updated_items": "已更新的记忆",
"com_ui_memory_would_exceed": "无法保存 - 将超过 {{tokens}} 词元限制。删除现有记忆以释放空间。",
"com_ui_mention": "提及一个端点、助手或预设以快速切换到它",
"com_ui_message_input": "消息输入",
"com_ui_min_tags": "无法再移除更多值,至少需要保留 {{0}} 个。",
"com_ui_minimal": "最小值",
"com_ui_misc": "杂项",
@@ -1074,6 +1095,8 @@
"com_ui_privacy_policy_url": "隐私政策链接",
"com_ui_prompt": "提示词",
"com_ui_prompt_groups": "提示词组列表",
"com_ui_prompt_input": "提示词输入",
"com_ui_prompt_input_field": "提示词文本输入框",
"com_ui_prompt_name": "提示词名称",
"com_ui_prompt_name_required": "提示词名称为必填项",
"com_ui_prompt_preview_not_shared": "作者未允许对此提示词进行协作。",
@@ -1092,11 +1115,13 @@
"com_ui_reference_saved_memories_description": "允许助手在回复时参考并使用您保存的记忆",
"com_ui_refresh": "刷新",
"com_ui_refresh_link": "刷新链接",
"com_ui_refresh_page": "刷新页面",
"com_ui_regenerate": "重新生成",
"com_ui_regenerate_backup": "重新生成备份代码",
"com_ui_regenerating": "重新生成中...",
"com_ui_region": "区域",
"com_ui_reinitialize": "重新初始化",
"com_ui_remove_agent_from_chain": "从链中移除 {{0}}",
"com_ui_remove_user": "移除 {{0}}",
"com_ui_rename": "重命名",
"com_ui_rename_conversation": "重命名对话",
@@ -1108,6 +1133,7 @@
"com_ui_reset_var": "重置 {{0}}",
"com_ui_reset_zoom": "重置缩放",
"com_ui_resource": "资源",
"com_ui_response": "响应",
"com_ui_result": "结果",
"com_ui_revoke": "撤销",
"com_ui_revoke_info": "撤销所有用户提供的凭据",
@@ -1172,6 +1198,7 @@
"com_ui_share_everyone": "与所有人共享",
"com_ui_share_everyone_description_var": "该{{resource}}将对所有人可用。请确保该{{resource}}适合与所有人共享。请注意保护您的数据。",
"com_ui_share_link_to_chat": "共享链接到聊天",
"com_ui_share_qr_code_description": "用于共享此对话链接的二维码",
"com_ui_share_update_message": "您的姓名、自定义指令以及您在共享后添加的任何消息将保持私密。",
"com_ui_share_var": "共享 {{0}}",
"com_ui_shared_link_bulk_delete_success": "成功删除分享链接",
@@ -1192,7 +1219,7 @@
"com_ui_special_var_iso_datetime": "UTC ISO 日期时间",
"com_ui_special_variables": "特殊变量:",
"com_ui_special_variables_more_info": "您可以从下拉菜单中选择特殊变量:`{{current_date}}`(今天的日期和星期)、`{{current_datetime}}`(本地日期和时间)、`{{utc_iso_datetime}}`UTC ISO 格式的日期时间)以及`{{current_user}}`(您的账户名称)。",
"com_ui_speech_while_submitting": "正在生成回复时无法提交语音",
"com_ui_speech_while_submitting": "正在生成响应时无法提交语音",
"com_ui_sr_actions_menu": "打开 \"{{0}}\" 的操作菜单",
"com_ui_stop": "停止",
"com_ui_storage": "存储",
@@ -1218,6 +1245,7 @@
"com_ui_tool_info": "工具信息",
"com_ui_tool_more_info": "有关此工具的更多信息",
"com_ui_tools": "工具",
"com_ui_transferred_to": "转移至",
"com_ui_travel": "旅行",
"com_ui_trust_app": "我信任此应用",
"com_ui_try_adjusting_search": "尝试调整您的搜索条件",
@@ -1233,6 +1261,7 @@
"com_ui_update_mcp_success": "已成功创建或更新 MCP",
"com_ui_upload": "上传",
"com_ui_upload_agent_avatar": "成功更新智能体头像",
"com_ui_upload_agent_avatar_label": "上传智能体头像图片",
"com_ui_upload_avatar_label": "上传头像图片",
"com_ui_upload_code_files": "上传代码解释器文件",
"com_ui_upload_delay": "上传 “{{0}}” 时比预期花了更长时间。文件正在进行检索索引,请稍候。",

View File

@@ -2715,6 +2715,7 @@ html {
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fadeIn {
from {
opacity: 0;
@@ -2730,10 +2731,6 @@ html {
animation: fadeIn 0.5s ease-out forwards;
}
.scale-98 {
transform: scale(0.98);
}
/* Chat Badges Animation */
@keyframes ios-wiggle {

View File

@@ -1,11 +1,6 @@
import { EModelEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
import type { TEndpointsConfig, TConfig } from 'librechat-data-provider';
import {
getEndpointField,
getAvailableEndpoints,
getEndpointsFilter,
mapEndpoints,
} from './endpoints';
import { getAvailableEndpoints, getEndpointsFilter, mapEndpoints } from './endpoints';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { type: undefined, iconURL: 'openAI_icon.png', order: 0 },

View File

@@ -4,6 +4,7 @@ import {
defaultEndpoints,
modularEndpoints,
LocalStorageKeys,
getEndpointField,
isAgentsEndpoint,
isAssistantsEndpoint,
} from 'librechat-data-provider';
@@ -58,24 +59,6 @@ export const getAvailableEndpoints = (
return availableEndpoints;
};
/** Get the specified field from the endpoint config */
export function getEndpointField<K extends keyof t.TConfig>(
endpointsConfig: t.TEndpointsConfig | undefined | null,
endpoint: EModelEndpoint | string | null | undefined,
property: K,
): t.TConfig[K] | undefined {
if (!endpointsConfig || endpoint === null || endpoint === undefined) {
return undefined;
}
const config = endpointsConfig[endpoint];
if (!config) {
return undefined;
}
return config[property];
}
export function mapEndpoints(endpointsConfig: t.TEndpointsConfig) {
const filter = getEndpointsFilter(endpointsConfig);
return getAvailableEndpoints(filter, endpointsConfig).sort(

View File

@@ -235,7 +235,13 @@ export const validateFiles = ({
toolResource?: string;
fileConfig: FileConfig | null;
}) => {
const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes } = endpointFileConfig;
const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes, disabled } =
endpointFileConfig;
/** Block all uploads if the endpoint is explicitly disabled */
if (disabled === true) {
setError('com_ui_attach_error_disabled');
return false;
}
const existingFiles = Array.from(files.values());
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
if (incomingTotalSize === 0) {

View File

@@ -8,6 +8,7 @@ export * from './forms';
export * from './agents';
export * from './drafts';
export * from './convos';
export * from './routes';
export * from './presets';
export * from './prompts';
export * from './textarea';

View File

@@ -0,0 +1,7 @@
import { matchPath } from 'react-router-dom';
const matchesRouteStart = (pathname: string, pattern: string) =>
matchPath({ path: pattern, end: false }, pathname) != null;
export const isArtifactRoute = (pathname: string) =>
matchesRouteStart(pathname, '/c/*') || matchesRouteStart(pathname, '/share/*');

Some files were not shown because too many files have changed in this diff Show More