Compare commits
37 Commits
feat/mcp-p
...
feat/segme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcfb0f47f9 | ||
|
|
f4d97e1672 | ||
|
|
035fa081c1 | ||
|
|
aecf8f19a6 | ||
|
|
35f548a94d | ||
|
|
e60c0cf201 | ||
|
|
5b392f9cb0 | ||
|
|
e0f468da20 | ||
|
|
91a2df4759 | ||
|
|
97a99985fa | ||
|
|
3554625a06 | ||
|
|
a37bf6719c | ||
|
|
e513f50c08 | ||
|
|
f5511e4a4e | ||
|
|
a288ad1d9c | ||
|
|
458580ec87 | ||
|
|
4285d5841c | ||
|
|
5ee55cda4f | ||
|
|
404d40cbef | ||
|
|
f4680b016c | ||
|
|
077224b351 | ||
|
|
9c70d1db96 | ||
|
|
543281da6c | ||
|
|
24800bfbeb | ||
|
|
07e08143e4 | ||
|
|
8ba61a86f4 | ||
|
|
56ad92fb1c | ||
|
|
1ceb52d2b5 | ||
|
|
5d267aa8e2 | ||
|
|
59d00e99f3 | ||
|
|
738d04fac4 | ||
|
|
8a5dbac0f9 | ||
|
|
434289fe92 | ||
|
|
a648ad3d13 | ||
|
|
55d63caaf4 | ||
|
|
313539d1ed | ||
|
|
f869d772f7 |
@@ -1,4 +1,4 @@
|
||||
# v0.7.8
|
||||
# v0.7.9-rc1
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.7.8
|
||||
# v0.7.9-rc1
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
- 🖥️ **UI & Experience** inspired by ChatGPT with enhanced design and features
|
||||
|
||||
- 🤖 **AI Model Selection**:
|
||||
- Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Assistants API (incl. Azure)
|
||||
- Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Responses API (incl. Azure)
|
||||
- [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,
|
||||
@@ -66,10 +66,9 @@
|
||||
- 🔦 **Agents & Tools Integration**:
|
||||
- **[LibreChat Agents](https://www.librechat.ai/docs/features/agents)**:
|
||||
- No-Code Custom Assistants: Build specialized, AI-driven helpers without coding
|
||||
- Flexible & Extensible: Attach tools like DALL-E-3, file search, code execution, and more
|
||||
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, and more
|
||||
- Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
|
||||
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
|
||||
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
|
||||
- Use LibreChat Agents and OpenAI Assistants with Files, Code Interpreter, Tools, and API Actions
|
||||
|
||||
- 🔍 **Web Search**:
|
||||
- Search the internet and retrieve relevant information to enhance your AI context
|
||||
|
||||
@@ -13,7 +13,6 @@ const {
|
||||
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models');
|
||||
const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { truncateToolCallOutputs } = require('./prompts');
|
||||
const { addSpaceIfNeeded } = require('~/server/utils');
|
||||
const { getFiles } = require('~/models/File');
|
||||
const TextStream = require('./TextStream');
|
||||
const { logger } = require('~/config');
|
||||
@@ -572,7 +571,7 @@ class BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
const { generation = '' } = opts;
|
||||
const { editedContent } = opts;
|
||||
|
||||
// It's not necessary to push to currentMessages
|
||||
// depending on subclass implementation of handling messages
|
||||
@@ -587,11 +586,21 @@ class BaseClient {
|
||||
isCreatedByUser: false,
|
||||
model: this.modelOptions?.model ?? this.model,
|
||||
sender: this.sender,
|
||||
text: generation,
|
||||
};
|
||||
this.currentMessages.push(userMessage, latestMessage);
|
||||
} else {
|
||||
latestMessage.text = generation;
|
||||
} else if (editedContent != null) {
|
||||
// Handle editedContent for content parts
|
||||
if (editedContent && latestMessage.content && Array.isArray(latestMessage.content)) {
|
||||
const { index, text, type } = editedContent;
|
||||
if (index >= 0 && index < latestMessage.content.length) {
|
||||
const contentPart = latestMessage.content[index];
|
||||
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
|
||||
contentPart[ContentTypes.THINK] = text;
|
||||
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
|
||||
contentPart[ContentTypes.TEXT] = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.continued = true;
|
||||
} else {
|
||||
@@ -672,16 +681,32 @@ class BaseClient {
|
||||
};
|
||||
|
||||
if (typeof completion === 'string') {
|
||||
responseMessage.text = addSpaceIfNeeded(generation) + completion;
|
||||
responseMessage.text = completion;
|
||||
} else if (
|
||||
Array.isArray(completion) &&
|
||||
(this.clientName === EModelEndpoint.agents ||
|
||||
isParamEndpoint(this.options.endpoint, this.options.endpointType))
|
||||
) {
|
||||
responseMessage.text = '';
|
||||
responseMessage.content = completion;
|
||||
|
||||
if (!opts.editedContent || this.currentMessages.length === 0) {
|
||||
responseMessage.content = completion;
|
||||
} else {
|
||||
const latestMessage = this.currentMessages[this.currentMessages.length - 1];
|
||||
if (!latestMessage?.content) {
|
||||
responseMessage.content = completion;
|
||||
} else {
|
||||
const existingContent = [...latestMessage.content];
|
||||
const { type: editedType } = opts.editedContent;
|
||||
responseMessage.content = this.mergeEditedContent(
|
||||
existingContent,
|
||||
completion,
|
||||
editedType,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(completion)) {
|
||||
responseMessage.text = addSpaceIfNeeded(generation) + completion.join('');
|
||||
responseMessage.text = completion.join('');
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1095,6 +1120,50 @@ class BaseClient {
|
||||
return numTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges completion content with existing content when editing TEXT or THINK types
|
||||
* @param {Array} existingContent - The existing content array
|
||||
* @param {Array} newCompletion - The new completion content
|
||||
* @param {string} editedType - The type of content being edited
|
||||
* @returns {Array} The merged content array
|
||||
*/
|
||||
mergeEditedContent(existingContent, newCompletion, editedType) {
|
||||
if (!newCompletion.length) {
|
||||
return existingContent.concat(newCompletion);
|
||||
}
|
||||
|
||||
if (editedType !== ContentTypes.TEXT && editedType !== ContentTypes.THINK) {
|
||||
return existingContent.concat(newCompletion);
|
||||
}
|
||||
|
||||
const lastIndex = existingContent.length - 1;
|
||||
const lastExisting = existingContent[lastIndex];
|
||||
const firstNew = newCompletion[0];
|
||||
|
||||
if (lastExisting?.type !== firstNew?.type || firstNew?.type !== editedType) {
|
||||
return existingContent.concat(newCompletion);
|
||||
}
|
||||
|
||||
const mergedContent = [...existingContent];
|
||||
if (editedType === ContentTypes.TEXT) {
|
||||
mergedContent[lastIndex] = {
|
||||
...mergedContent[lastIndex],
|
||||
[ContentTypes.TEXT]:
|
||||
(mergedContent[lastIndex][ContentTypes.TEXT] || '') + (firstNew[ContentTypes.TEXT] || ''),
|
||||
};
|
||||
} else {
|
||||
mergedContent[lastIndex] = {
|
||||
...mergedContent[lastIndex],
|
||||
[ContentTypes.THINK]:
|
||||
(mergedContent[lastIndex][ContentTypes.THINK] || '') +
|
||||
(firstNew[ContentTypes.THINK] || ''),
|
||||
};
|
||||
}
|
||||
|
||||
// Add remaining completion items
|
||||
return mergedContent.concat(newCompletion.slice(1));
|
||||
}
|
||||
|
||||
async sendPayload(payload, opts = {}) {
|
||||
if (opts && typeof opts === 'object') {
|
||||
this.setOptions(opts);
|
||||
|
||||
@@ -90,7 +90,7 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
|
||||
}
|
||||
|
||||
const instructions = req.body.promptPrefix;
|
||||
return {
|
||||
const result = {
|
||||
id: agent_id,
|
||||
instructions,
|
||||
provider: endpoint,
|
||||
@@ -98,6 +98,11 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
|
||||
model,
|
||||
tools,
|
||||
};
|
||||
|
||||
if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
|
||||
result.artifacts = ephemeralAgent.artifacts;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.7.8",
|
||||
"version": "v0.7.9-rc1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.46",
|
||||
"@librechat/agents": "^2.4.56",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@node-saml/passport-saml": "^5.0.0",
|
||||
|
||||
@@ -24,17 +24,23 @@ const handleValidationError = (err, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
module.exports = (err, req, res, next) => {
|
||||
module.exports = (err, _req, res, _next) => {
|
||||
try {
|
||||
if (err.name === 'ValidationError') {
|
||||
return (err = handleValidationError(err, res));
|
||||
return handleValidationError(err, res);
|
||||
}
|
||||
if (err.code && err.code == 11000) {
|
||||
return (err = handleDuplicateKeyError(err, res));
|
||||
return handleDuplicateKeyError(err, res);
|
||||
}
|
||||
} catch (err) {
|
||||
// Special handling for errors like SyntaxError
|
||||
if (err.statusCode && err.body) {
|
||||
return res.status(err.statusCode).send(err.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
res.status(500).send('An unknown error occurred.');
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (err) {
|
||||
logger.error('ErrorController => processing error', err);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
||||
|
||||
241
api/server/controllers/ErrorController.spec.js
Normal file
241
api/server/controllers/ErrorController.spec.js
Normal file
@@ -0,0 +1,241 @@
|
||||
const errorController = require('./ErrorController');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ErrorController', () => {
|
||||
let mockReq, mockRes, mockNext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReq = {};
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mockNext = jest.fn();
|
||||
logger.error.mockClear();
|
||||
});
|
||||
|
||||
describe('ValidationError handling', () => {
|
||||
it('should handle ValidationError with single error', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
},
|
||||
};
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: '["Email is required"]',
|
||||
fields: '["email"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Validation error:', validationError.errors);
|
||||
});
|
||||
|
||||
it('should handle ValidationError with multiple errors', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
password: { message: 'Password is required', path: 'password' },
|
||||
},
|
||||
};
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: '"Email is required Password is required"',
|
||||
fields: '["email","password"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Validation error:', validationError.errors);
|
||||
});
|
||||
|
||||
it('should handle ValidationError with empty errors object', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
errors: {},
|
||||
};
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: '[]',
|
||||
fields: '[]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Duplicate key error handling', () => {
|
||||
it('should handle duplicate key error (code 11000)', () => {
|
||||
const duplicateKeyError = {
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email"] already exists.',
|
||||
fields: '["email"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
});
|
||||
|
||||
it('should handle duplicate key error with multiple fields', () => {
|
||||
const duplicateKeyError = {
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com', username: 'testuser' },
|
||||
};
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email","username"] already exists.',
|
||||
fields: '["email","username"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
});
|
||||
|
||||
it('should handle error with code 11000 as string', () => {
|
||||
const duplicateKeyError = {
|
||||
code: '11000',
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email"] already exists.',
|
||||
fields: '["email"]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyntaxError handling', () => {
|
||||
it('should handle errors with statusCode and body', () => {
|
||||
const syntaxError = {
|
||||
statusCode: 400,
|
||||
body: 'Invalid JSON syntax',
|
||||
};
|
||||
|
||||
errorController(syntaxError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
|
||||
});
|
||||
|
||||
it('should handle errors with different statusCode and body', () => {
|
||||
const customError = {
|
||||
statusCode: 422,
|
||||
body: { error: 'Unprocessable entity' },
|
||||
};
|
||||
|
||||
errorController(customError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(422);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
|
||||
});
|
||||
|
||||
it('should handle error with statusCode but no body', () => {
|
||||
const partialError = {
|
||||
statusCode: 400,
|
||||
};
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
});
|
||||
|
||||
it('should handle error with body but no statusCode', () => {
|
||||
const partialError = {
|
||||
body: 'Some error message',
|
||||
};
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown error handling', () => {
|
||||
it('should handle unknown errors', () => {
|
||||
const unknownError = new Error('Some unknown error');
|
||||
|
||||
errorController(unknownError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', unknownError);
|
||||
});
|
||||
|
||||
it('should handle errors with code other than 11000', () => {
|
||||
const mongoError = {
|
||||
code: 11100,
|
||||
message: 'Some MongoDB error',
|
||||
};
|
||||
|
||||
errorController(mongoError, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', mongoError);
|
||||
});
|
||||
|
||||
it('should handle null/undefined errors', () => {
|
||||
errorController(null, mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'ErrorController => processing error',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Catch block handling', () => {
|
||||
beforeEach(() => {
|
||||
// Restore logger mock to normal behavior for these tests
|
||||
logger.error.mockRestore();
|
||||
logger.error = jest.fn();
|
||||
});
|
||||
|
||||
it('should handle errors when logger.error throws', () => {
|
||||
// Create fresh mocks for this test
|
||||
const freshMockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock logger to throw on the first call, succeed on the second
|
||||
logger.error
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Logger error');
|
||||
})
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const testError = new Error('Test error');
|
||||
|
||||
errorController(testError, mockReq, freshMockRes, mockNext);
|
||||
|
||||
expect(freshMockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -525,7 +525,10 @@ class AgentClient extends BaseClient {
|
||||
messagesToProcess = [...messages.slice(-messageWindowSize)];
|
||||
}
|
||||
}
|
||||
return await this.processMemory(messagesToProcess);
|
||||
|
||||
const bufferString = getBufferString(messagesToProcess);
|
||||
const bufferMessage = new HumanMessage(`# Current Chat:\n\n${bufferString}`);
|
||||
return await this.processMemory([bufferMessage]);
|
||||
} catch (error) {
|
||||
logger.error('Memory Agent failed to process memory', error);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
text,
|
||||
endpointOption,
|
||||
conversationId,
|
||||
isContinued = false,
|
||||
editedContent = null,
|
||||
parentMessageId = null,
|
||||
overrideParentMessageId = null,
|
||||
responseMessageId: editedResponseMessageId = null,
|
||||
} = req.body;
|
||||
|
||||
let sender;
|
||||
@@ -67,7 +70,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
handler();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
logger.error('[AgentController] Error in cleanup handler', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +158,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
try {
|
||||
res.removeListener('close', closeHandler);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
logger.error('[AgentController] Error removing close listener', e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,10 +166,14 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
user: userId,
|
||||
onStart,
|
||||
getReqData,
|
||||
isContinued,
|
||||
editedContent,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
abortController,
|
||||
overrideParentMessageId,
|
||||
isEdited: !!editedContent,
|
||||
responseMessageId: editedResponseMessageId,
|
||||
progressOptions: {
|
||||
res,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const { z } = require('zod');
|
||||
const fs = require('fs').promises;
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
@@ -8,6 +10,7 @@ const {
|
||||
SystemRoles,
|
||||
EToolResources,
|
||||
actionDelimiter,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
getAgent,
|
||||
@@ -30,6 +33,7 @@ const { deleteFileByFilter } = require('~/models/File');
|
||||
const systemTools = {
|
||||
[Tools.execute_code]: true,
|
||||
[Tools.file_search]: true,
|
||||
[Tools.web_search]: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -42,9 +46,13 @@ const systemTools = {
|
||||
*/
|
||||
const createAgentHandler = async (req, res) => {
|
||||
try {
|
||||
const { tools = [], provider, name, description, instructions, model, ...agentData } = req.body;
|
||||
const validatedData = agentCreateSchema.parse(req.body);
|
||||
const { tools = [], ...agentData } = removeNullishValues(validatedData);
|
||||
|
||||
const { id: userId } = req.user;
|
||||
|
||||
agentData.id = `agent_${nanoid()}`;
|
||||
agentData.author = userId;
|
||||
agentData.tools = [];
|
||||
|
||||
const availableTools = await getCachedTools({ includeGlobal: true });
|
||||
@@ -58,19 +66,13 @@ const createAgentHandler = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(agentData, {
|
||||
author: userId,
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
|
||||
agentData.id = `agent_${nanoid()}`;
|
||||
const agent = await createAgent(agentData);
|
||||
res.status(201).json(agent);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error('[/Agents] Validation error', error.errors);
|
||||
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
|
||||
}
|
||||
logger.error('[/Agents] Error creating agent', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -154,14 +156,16 @@ const getAgentHandler = async (req, res) => {
|
||||
const updateAgentHandler = async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const { projectIds, removeProjectIds, ...updateData } = req.body;
|
||||
const validatedData = agentUpdateSchema.parse(req.body);
|
||||
const { projectIds, removeProjectIds, ...updateData } = removeNullishValues(validatedData);
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const existingAgent = await getAgent({ id });
|
||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||
|
||||
if (!hasEditPermission) {
|
||||
@@ -200,6 +204,11 @@ const updateAgentHandler = async (req, res) => {
|
||||
|
||||
return res.json(updatedAgent);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error('[/Agents/:id] Validation error', error.errors);
|
||||
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
|
||||
}
|
||||
|
||||
logger.error('[/Agents/:id] Error updating Agent', error);
|
||||
|
||||
if (error.statusCode === 409) {
|
||||
|
||||
659
api/server/controllers/agents/v1.spec.js
Normal file
659
api/server/controllers/agents/v1.spec.js
Normal file
@@ -0,0 +1,659 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { agentSchema } = require('@librechat/data-schemas');
|
||||
|
||||
// Only mock the dependencies that are not database-related
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getCachedTools: jest.fn().mockResolvedValue({
|
||||
web_search: true,
|
||||
execute_code: true,
|
||||
file_search: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Project', () => ({
|
||||
getProjectByName: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images/avatar', () => ({
|
||||
resizeAvatar: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/S3/crud', () => ({
|
||||
refreshS3Url: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
filterFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Action', () => ({
|
||||
updateAction: jest.fn(),
|
||||
getActions: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/File', () => ({
|
||||
deleteFileByFilter: jest.fn(),
|
||||
}));
|
||||
|
||||
const { createAgent: createAgentHandler, updateAgent: updateAgentHandler } = require('./v1');
|
||||
|
||||
/**
|
||||
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
||||
*/
|
||||
let Agent;
|
||||
|
||||
describe('Agent Controllers - Mass Assignment Protection', () => {
|
||||
let mongoServer;
|
||||
let mockReq;
|
||||
let mockRes;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
||||
}, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await Agent.deleteMany({});
|
||||
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup mock request and response objects
|
||||
mockReq = {
|
||||
user: {
|
||||
id: new mongoose.Types.ObjectId().toString(),
|
||||
role: 'USER',
|
||||
},
|
||||
body: {},
|
||||
params: {},
|
||||
app: {
|
||||
locals: {
|
||||
fileStrategy: 'local',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('createAgentHandler', () => {
|
||||
test('should create agent with allowed fields only', async () => {
|
||||
const validData = {
|
||||
name: 'Test Agent',
|
||||
description: 'A test agent',
|
||||
instructions: 'Be helpful',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['web_search'],
|
||||
model_parameters: { temperature: 0.7 },
|
||||
tool_resources: {
|
||||
file_search: { file_ids: ['file1', 'file2'] },
|
||||
},
|
||||
};
|
||||
|
||||
mockReq.body = validData;
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(createdAgent.name).toBe('Test Agent');
|
||||
expect(createdAgent.description).toBe('A test agent');
|
||||
expect(createdAgent.provider).toBe('openai');
|
||||
expect(createdAgent.model).toBe('gpt-4');
|
||||
expect(createdAgent.author.toString()).toBe(mockReq.user.id);
|
||||
expect(createdAgent.tools).toContain('web_search');
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
||||
expect(agentInDb).toBeDefined();
|
||||
expect(agentInDb.name).toBe('Test Agent');
|
||||
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
|
||||
});
|
||||
|
||||
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
|
||||
const maliciousData = {
|
||||
// Required fields
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'Malicious Agent',
|
||||
|
||||
// Unauthorized fields that should be stripped
|
||||
author: new mongoose.Types.ObjectId().toString(), // Should not be able to set author
|
||||
authorName: 'Hacker', // Should be stripped
|
||||
isCollaborative: true, // Should be stripped on creation
|
||||
versions: [], // Should be stripped
|
||||
_id: new mongoose.Types.ObjectId(), // Should be stripped
|
||||
id: 'custom_agent_id', // Should be overridden
|
||||
createdAt: new Date('2020-01-01'), // Should be stripped
|
||||
updatedAt: new Date('2020-01-01'), // Should be stripped
|
||||
};
|
||||
|
||||
mockReq.body = maliciousData;
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
|
||||
// Verify unauthorized fields were not set
|
||||
expect(createdAgent.author.toString()).toBe(mockReq.user.id); // Should be the request user, not the malicious value
|
||||
expect(createdAgent.authorName).toBeUndefined();
|
||||
expect(createdAgent.isCollaborative).toBeFalsy();
|
||||
expect(createdAgent.versions).toHaveLength(1); // Should have exactly 1 version from creation
|
||||
expect(createdAgent.id).not.toBe('custom_agent_id'); // Should have generated ID
|
||||
expect(createdAgent.id).toMatch(/^agent_/); // Should have proper prefix
|
||||
|
||||
// Verify timestamps are recent (not the malicious dates)
|
||||
const createdTime = new Date(createdAgent.createdAt).getTime();
|
||||
const now = Date.now();
|
||||
expect(now - createdTime).toBeLessThan(5000); // Created within last 5 seconds
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
||||
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
|
||||
expect(agentInDb.authorName).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should validate required fields', async () => {
|
||||
const invalidData = {
|
||||
name: 'Missing Required Fields',
|
||||
// Missing provider and model
|
||||
};
|
||||
|
||||
mockReq.body = invalidData;
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Invalid request data',
|
||||
details: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify nothing was created in database
|
||||
const count = await Agent.countDocuments();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle tool_resources validation', async () => {
|
||||
const dataWithInvalidToolResources = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'Agent with Tool Resources',
|
||||
tool_resources: {
|
||||
// Valid resources
|
||||
file_search: {
|
||||
file_ids: ['file1', 'file2'],
|
||||
vector_store_ids: ['vs1'],
|
||||
},
|
||||
execute_code: {
|
||||
file_ids: ['file3'],
|
||||
},
|
||||
// Invalid resource (should be stripped by schema)
|
||||
invalid_resource: {
|
||||
file_ids: ['file4'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockReq.body = dataWithInvalidToolResources;
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(createdAgent.tool_resources).toBeDefined();
|
||||
expect(createdAgent.tool_resources.file_search).toBeDefined();
|
||||
expect(createdAgent.tool_resources.execute_code).toBeDefined();
|
||||
expect(createdAgent.tool_resources.invalid_resource).toBeUndefined(); // Should be stripped
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
||||
expect(agentInDb.tool_resources.invalid_resource).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle avatar validation', async () => {
|
||||
const dataWithAvatar = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'Agent with Avatar',
|
||||
avatar: {
|
||||
filepath: 'https://example.com/avatar.png',
|
||||
source: 's3',
|
||||
},
|
||||
};
|
||||
|
||||
mockReq.body = dataWithAvatar;
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(createdAgent.avatar).toEqual({
|
||||
filepath: 'https://example.com/avatar.png',
|
||||
source: 's3',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle invalid avatar format', async () => {
|
||||
const dataWithInvalidAvatar = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'Agent with Invalid Avatar',
|
||||
avatar: 'just-a-string', // Invalid format
|
||||
};
|
||||
|
||||
mockReq.body = dataWithInvalidAvatar;
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Invalid request data',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAgentHandler', () => {
|
||||
let existingAgentId;
|
||||
let existingAgentAuthorId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create an existing agent for update tests
|
||||
existingAgentAuthorId = new mongoose.Types.ObjectId();
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Original Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-3.5-turbo',
|
||||
author: existingAgentAuthorId,
|
||||
description: 'Original description',
|
||||
isCollaborative: false,
|
||||
versions: [
|
||||
{
|
||||
name: 'Original Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-3.5-turbo',
|
||||
description: 'Original description',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
existingAgentId = agent.id;
|
||||
});
|
||||
|
||||
test('should update agent with allowed fields only', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString(); // Set as author
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Updated Agent',
|
||||
description: 'Updated description',
|
||||
model: 'gpt-4',
|
||||
isCollaborative: true, // This IS allowed in updates
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(400);
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.name).toBe('Updated Agent');
|
||||
expect(updatedAgent.description).toBe('Updated description');
|
||||
expect(updatedAgent.model).toBe('gpt-4');
|
||||
expect(updatedAgent.isCollaborative).toBe(true);
|
||||
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString());
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.name).toBe('Updated Agent');
|
||||
expect(agentInDb.isCollaborative).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject update with unauthorized fields (mass assignment protection)', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Updated Name',
|
||||
|
||||
// Unauthorized fields that should be stripped
|
||||
author: new mongoose.Types.ObjectId().toString(), // Should not be able to change author
|
||||
authorName: 'Hacker', // Should be stripped
|
||||
id: 'different_agent_id', // Should be stripped
|
||||
_id: new mongoose.Types.ObjectId(), // Should be stripped
|
||||
versions: [], // Should be stripped
|
||||
createdAt: new Date('2020-01-01'), // Should be stripped
|
||||
updatedAt: new Date('2020-01-01'), // Should be stripped
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
|
||||
// Verify unauthorized fields were not changed
|
||||
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString()); // Should not have changed
|
||||
expect(updatedAgent.authorName).toBeUndefined();
|
||||
expect(updatedAgent.id).toBe(existingAgentId); // Should not have changed
|
||||
expect(updatedAgent.name).toBe('Updated Name'); // Only this should have changed
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.author.toString()).toBe(existingAgentAuthorId.toString());
|
||||
expect(agentInDb.id).toBe(existingAgentId);
|
||||
});
|
||||
|
||||
test('should reject update from non-author when not collaborative', async () => {
|
||||
const differentUserId = new mongoose.Types.ObjectId().toString();
|
||||
mockReq.user.id = differentUserId; // Different user
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Unauthorized Update',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
error: 'You do not have permission to modify this non-collaborative agent',
|
||||
});
|
||||
|
||||
// Verify agent was not modified in database
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.name).toBe('Original Agent');
|
||||
});
|
||||
|
||||
test('should allow update from non-author when collaborative', async () => {
|
||||
// First make the agent collaborative
|
||||
await Agent.updateOne({ id: existingAgentId }, { isCollaborative: true });
|
||||
|
||||
const differentUserId = new mongoose.Types.ObjectId().toString();
|
||||
mockReq.user.id = differentUserId; // Different user
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Collaborative Update',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.name).toBe('Collaborative Update');
|
||||
// Author field should be removed for non-author
|
||||
expect(updatedAgent.author).toBeUndefined();
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.name).toBe('Collaborative Update');
|
||||
});
|
||||
|
||||
test('should allow admin to update any agent', async () => {
|
||||
const adminUserId = new mongoose.Types.ObjectId().toString();
|
||||
mockReq.user.id = adminUserId;
|
||||
mockReq.user.role = 'ADMIN'; // Set as admin
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Admin Update',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).not.toHaveBeenCalledWith(403);
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.name).toBe('Admin Update');
|
||||
});
|
||||
|
||||
test('should handle projectIds updates', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
|
||||
const projectId1 = new mongoose.Types.ObjectId().toString();
|
||||
const projectId2 = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
mockReq.body = {
|
||||
projectIds: [projectId1, projectId2],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent).toBeDefined();
|
||||
// Note: updateAgentProjects requires more setup, so we just verify the handler doesn't crash
|
||||
});
|
||||
|
||||
test('should validate tool_resources in updates', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tool_resources: {
|
||||
ocr: {
|
||||
file_ids: ['ocr1', 'ocr2'],
|
||||
},
|
||||
execute_code: {
|
||||
file_ids: ['img1'],
|
||||
},
|
||||
// Invalid tool resource
|
||||
invalid_tool: {
|
||||
file_ids: ['invalid'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tool_resources).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.ocr).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.execute_code).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return 404 for non-existent agent', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
|
||||
mockReq.body = {
|
||||
name: 'Update Non-existent',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Agent not found' });
|
||||
});
|
||||
|
||||
test('should handle validation errors properly', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
model_parameters: 'invalid-not-an-object', // Should be an object
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Invalid request data',
|
||||
details: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mass Assignment Attack Scenarios', () => {
|
||||
test('should prevent setting system fields during creation', async () => {
|
||||
const systemFields = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'System Fields Test',
|
||||
|
||||
// System fields that should never be settable by users
|
||||
__v: 99,
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
versions: [
|
||||
{
|
||||
name: 'Fake Version',
|
||||
provider: 'fake',
|
||||
model: 'fake-model',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockReq.body = systemFields;
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
|
||||
// Verify system fields were not affected
|
||||
expect(createdAgent.__v).not.toBe(99);
|
||||
expect(createdAgent.versions).toHaveLength(1); // Should only have the auto-created version
|
||||
expect(createdAgent.versions[0].name).toBe('System Fields Test'); // From actual creation
|
||||
expect(createdAgent.versions[0].provider).toBe('openai'); // From actual creation
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
||||
expect(agentInDb.__v).not.toBe(99);
|
||||
});
|
||||
|
||||
test('should prevent privilege escalation through isCollaborative', async () => {
|
||||
// Create a non-collaborative agent
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Private Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: authorId,
|
||||
isCollaborative: false,
|
||||
versions: [
|
||||
{
|
||||
name: 'Private Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Try to make it collaborative as a different user
|
||||
const attackerId = new mongoose.Types.ObjectId().toString();
|
||||
mockReq.user.id = attackerId;
|
||||
mockReq.params.id = agent.id;
|
||||
mockReq.body = {
|
||||
isCollaborative: true, // Trying to escalate privileges
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
// Should be rejected
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
|
||||
// Verify in database that it's still not collaborative
|
||||
const agentInDb = await Agent.findOne({ id: agent.id });
|
||||
expect(agentInDb.isCollaborative).toBe(false);
|
||||
});
|
||||
|
||||
test('should prevent author hijacking', async () => {
|
||||
const originalAuthorId = new mongoose.Types.ObjectId();
|
||||
const attackerId = new mongoose.Types.ObjectId();
|
||||
|
||||
// Admin creates an agent
|
||||
mockReq.user.id = originalAuthorId.toString();
|
||||
mockReq.user.role = 'ADMIN';
|
||||
mockReq.body = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'Admin Agent',
|
||||
author: attackerId.toString(), // Trying to set different author
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
|
||||
// Author should be the actual user, not the attempted value
|
||||
expect(createdAgent.author.toString()).toBe(originalAuthorId.toString());
|
||||
expect(createdAgent.author.toString()).not.toBe(attackerId.toString());
|
||||
|
||||
// Verify in database
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id });
|
||||
expect(agentInDb.author.toString()).toBe(originalAuthorId.toString());
|
||||
});
|
||||
|
||||
test('should strip unknown fields to prevent future vulnerabilities', async () => {
|
||||
mockReq.body = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'Future Proof Test',
|
||||
|
||||
// Unknown fields that might be added in future
|
||||
superAdminAccess: true,
|
||||
bypassAllChecks: true,
|
||||
internalFlag: 'secret',
|
||||
futureFeature: 'exploit',
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
|
||||
// Verify unknown fields were stripped
|
||||
expect(createdAgent.superAdminAccess).toBeUndefined();
|
||||
expect(createdAgent.bypassAllChecks).toBeUndefined();
|
||||
expect(createdAgent.internalFlag).toBeUndefined();
|
||||
expect(createdAgent.futureFeature).toBeUndefined();
|
||||
|
||||
// Also check in database
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
|
||||
expect(agentInDb.superAdminAccess).toBeUndefined();
|
||||
expect(agentInDb.bypassAllChecks).toBeUndefined();
|
||||
expect(agentInDb.internalFlag).toBeUndefined();
|
||||
expect(agentInDb.futureFeature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,6 @@ const startServer = async () => {
|
||||
|
||||
/* Middleware */
|
||||
app.use(noIndex);
|
||||
app.use(errorController);
|
||||
app.use(express.json({ limit: '3mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
||||
app.use(mongoSanitize());
|
||||
@@ -121,6 +120,9 @@ const startServer = async () => {
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
// Add the error controller one more time after all routes
|
||||
app.use(errorController);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.set({
|
||||
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const request = require('supertest');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const mongoose = require('mongoose');
|
||||
@@ -59,6 +58,30 @@ describe('Server Configuration', () => {
|
||||
expect(response.headers['pragma']).toBe('no-cache');
|
||||
expect(response.headers['expires']).toBe('0');
|
||||
});
|
||||
|
||||
it('should return 500 for unknown errors via ErrorController', async () => {
|
||||
// Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated
|
||||
|
||||
// Mock MongoDB operations to fail
|
||||
const originalFindOne = mongoose.models.User.findOne;
|
||||
const mockError = new Error('MongoDB operation failed');
|
||||
mongoose.models.User.findOne = jest.fn().mockImplementation(() => {
|
||||
throw mockError;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app).post('/api/auth/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('An unknown error occurred.');
|
||||
} finally {
|
||||
// Restore original function
|
||||
mongoose.models.User.findOne = originalFindOne;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
|
||||
|
||||
@@ -18,7 +18,6 @@ const message = 'Your account has been temporarily banned due to violations of o
|
||||
* @function
|
||||
* @param {Object} req - Express Request object.
|
||||
* @param {Object} res - Express Response object.
|
||||
* @param {String} errorMessage - Error message to be displayed in case of /api/ask or /api/edit request.
|
||||
*
|
||||
* @returns {Promise<Object>} - Returns a Promise which when resolved sends a response status of 403 with a specific message if request is not of api/ask or api/edit types. If it is, calls `denyRequest()` function.
|
||||
*/
|
||||
@@ -135,6 +134,7 @@ const checkBan = async (req, res, next = () => {}) => {
|
||||
return await banResponse(req, res);
|
||||
} catch (error) {
|
||||
logger.error('Error in checkBan middleware:', error);
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
88
api/server/middleware/limiters/forkLimiters.js
Normal file
88
api/server/middleware/limiters/forkLimiters.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { RedisStore } = require('rate-limit-redis');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const ioredisClient = require('~/cache/ioredisClient');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
const getEnvironmentVariables = () => {
|
||||
const FORK_IP_MAX = parseInt(process.env.FORK_IP_MAX) || 30;
|
||||
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
|
||||
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
|
||||
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
|
||||
|
||||
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
|
||||
const forkIpMax = FORK_IP_MAX;
|
||||
const forkIpWindowInMinutes = forkIpWindowMs / 60000;
|
||||
|
||||
const forkUserWindowMs = FORK_USER_WINDOW * 60 * 1000;
|
||||
const forkUserMax = FORK_USER_MAX;
|
||||
const forkUserWindowInMinutes = forkUserWindowMs / 60000;
|
||||
|
||||
return {
|
||||
forkIpWindowMs,
|
||||
forkIpMax,
|
||||
forkIpWindowInMinutes,
|
||||
forkUserWindowMs,
|
||||
forkUserMax,
|
||||
forkUserWindowInMinutes,
|
||||
};
|
||||
};
|
||||
|
||||
const createForkHandler = (ip = true) => {
|
||||
const { forkIpMax, forkIpWindowInMinutes, forkUserMax, forkUserWindowInMinutes } =
|
||||
getEnvironmentVariables();
|
||||
|
||||
return async (req, res) => {
|
||||
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
||||
const errorMessage = {
|
||||
type,
|
||||
max: ip ? forkIpMax : forkUserMax,
|
||||
limiter: ip ? 'ip' : 'user',
|
||||
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage);
|
||||
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
|
||||
};
|
||||
};
|
||||
|
||||
const createForkLimiters = () => {
|
||||
const { forkIpWindowMs, forkIpMax, forkUserWindowMs, forkUserMax } = getEnvironmentVariables();
|
||||
|
||||
const ipLimiterOptions = {
|
||||
windowMs: forkIpWindowMs,
|
||||
max: forkIpMax,
|
||||
handler: createForkHandler(),
|
||||
};
|
||||
const userLimiterOptions = {
|
||||
windowMs: forkUserWindowMs,
|
||||
max: forkUserMax,
|
||||
handler: createForkHandler(false),
|
||||
keyGenerator: function (req) {
|
||||
return req.user?.id;
|
||||
},
|
||||
};
|
||||
|
||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||
logger.debug('Using Redis for fork rate limiters.');
|
||||
const sendCommand = (...args) => ioredisClient.call(...args);
|
||||
const ipStore = new RedisStore({
|
||||
sendCommand,
|
||||
prefix: 'fork_ip_limiter:',
|
||||
});
|
||||
const userStore = new RedisStore({
|
||||
sendCommand,
|
||||
prefix: 'fork_user_limiter:',
|
||||
});
|
||||
ipLimiterOptions.store = ipStore;
|
||||
userLimiterOptions.store = userStore;
|
||||
}
|
||||
|
||||
const forkIpLimiter = rateLimit(ipLimiterOptions);
|
||||
const forkUserLimiter = rateLimit(userLimiterOptions);
|
||||
return { forkIpLimiter, forkUserLimiter };
|
||||
};
|
||||
|
||||
module.exports = { createForkLimiters };
|
||||
@@ -1,10 +1,10 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { RedisStore } = require('rate-limit-redis');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const ioredisClient = require('~/cache/ioredisClient');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const getEnvironmentVariables = () => {
|
||||
const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;
|
||||
|
||||
@@ -4,6 +4,7 @@ const createSTTLimiters = require('./sttLimiters');
|
||||
const loginLimiter = require('./loginLimiter');
|
||||
const importLimiters = require('./importLimiters');
|
||||
const uploadLimiters = require('./uploadLimiters');
|
||||
const forkLimiters = require('./forkLimiters');
|
||||
const registerLimiter = require('./registerLimiter');
|
||||
const toolCallLimiter = require('./toolCallLimiter');
|
||||
const messageLimiters = require('./messageLimiters');
|
||||
@@ -14,6 +15,7 @@ module.exports = {
|
||||
...uploadLimiters,
|
||||
...importLimiters,
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
const multer = require('multer');
|
||||
const express = require('express');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
||||
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
||||
const { createImportLimiters, createForkLimiters } = require('~/server/middleware');
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { createImportLimiters } = require('~/server/middleware');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { isEnabled, sleep } = require('~/server/utils');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const assistantClients = {
|
||||
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
||||
@@ -43,6 +44,7 @@ router.get('/', async (req, res) => {
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching conversations', error);
|
||||
res.status(500).json({ error: 'Error fetching conversations' });
|
||||
}
|
||||
});
|
||||
@@ -156,6 +158,7 @@ router.post('/update', async (req, res) => {
|
||||
});
|
||||
|
||||
const { importIpLimiter, importUserLimiter } = createImportLimiters();
|
||||
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
|
||||
const upload = multer({ storage: storage, fileFilter: importFileFilter });
|
||||
|
||||
/**
|
||||
@@ -189,7 +192,7 @@ router.post(
|
||||
* @param {express.Response<TForkConvoResponse>} res - Express response object.
|
||||
* @returns {Promise<void>} - The response after forking the conversation.
|
||||
*/
|
||||
router.post('/fork', async (req, res) => {
|
||||
router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
|
||||
try {
|
||||
/** @type {TForkConvoRequest} */
|
||||
const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;
|
||||
|
||||
@@ -477,7 +477,9 @@ describe('Multer Configuration', () => {
|
||||
done(new Error('Expected mkdirSync to throw an error but no error was thrown'));
|
||||
} catch (error) {
|
||||
// This is the expected behavior - mkdirSync throws synchronously for invalid paths
|
||||
expect(error.code).toBe('EACCES');
|
||||
// On Linux, this typically returns EACCES (permission denied)
|
||||
// On macOS/Darwin, this returns ENOENT (no such file or directory)
|
||||
expect(['EACCES', 'ENOENT']).toContain(error.code);
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,13 +5,6 @@ const { CacheKeys } = require('librechat-data-provider');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { getFlowStateManager } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const {
|
||||
getMCPServers,
|
||||
getMCPServer,
|
||||
createMCPServer,
|
||||
updateMCPServer,
|
||||
deleteMCPServer,
|
||||
} = require('@librechat/api');
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -209,44 +202,4 @@ router.get('/oauth/status/:flowId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all MCP servers for the authenticated user
|
||||
* @route GET /api/mcp
|
||||
* @returns {Array} Array of MCP servers
|
||||
*/
|
||||
router.get('/', requireJwtAuth, getMCPServers);
|
||||
|
||||
/**
|
||||
* Get a single MCP server by ID
|
||||
* @route GET /api/mcp/:mcp_id
|
||||
* @param {string} mcp_id - The ID of the MCP server to fetch
|
||||
* @returns {object} MCP server data
|
||||
*/
|
||||
router.get('/:mcp_id', requireJwtAuth, getMCPServer);
|
||||
|
||||
/**
|
||||
* Create a new MCP server
|
||||
* @route POST /api/mcp/add
|
||||
* @param {object} req.body - MCP server data
|
||||
* @returns {object} Created MCP server with populated tools
|
||||
*/
|
||||
router.post('/add', requireJwtAuth, createMCPServer);
|
||||
|
||||
/**
|
||||
* Update an existing MCP server
|
||||
* @route PUT /api/mcp/:mcp_id
|
||||
* @param {string} mcp_id - The ID of the MCP server to update
|
||||
* @param {object} req.body - Updated MCP server data
|
||||
* @returns {object} Updated MCP server with populated tools
|
||||
*/
|
||||
router.put('/:mcp_id', requireJwtAuth, updateMCPServer);
|
||||
|
||||
/**
|
||||
* Delete an MCP server
|
||||
* @route DELETE /api/mcp/:mcp_id
|
||||
* @param {string} mcp_id - The ID of the MCP server to delete
|
||||
* @returns {object} Deletion confirmation
|
||||
*/
|
||||
router.delete('/:mcp_id', requireJwtAuth, deleteMCPServer);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -235,12 +235,13 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
|
||||
return res.status(400).json({ error: 'Content part not found' });
|
||||
}
|
||||
|
||||
if (updatedContent[index].type !== ContentTypes.TEXT) {
|
||||
const currentPartType = updatedContent[index].type;
|
||||
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
|
||||
return res.status(400).json({ error: 'Cannot update non-text content' });
|
||||
}
|
||||
|
||||
const oldText = updatedContent[index].text;
|
||||
updatedContent[index] = { type: ContentTypes.TEXT, text };
|
||||
const oldText = updatedContent[index][currentPartType];
|
||||
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
|
||||
|
||||
let tokenCount = message.tokenCount;
|
||||
if (tokenCount !== undefined) {
|
||||
|
||||
@@ -152,12 +152,14 @@ describe('AppService', () => {
|
||||
filteredTools: undefined,
|
||||
includedTools: undefined,
|
||||
webSearch: {
|
||||
safeSearch: 1,
|
||||
jinaApiKey: '${JINA_API_KEY}',
|
||||
cohereApiKey: '${COHERE_API_KEY}',
|
||||
serperApiKey: '${SERPER_API_KEY}',
|
||||
searxngApiKey: '${SEARXNG_API_KEY}',
|
||||
firecrawlApiKey: '${FIRECRAWL_API_KEY}',
|
||||
firecrawlApiUrl: '${FIRECRAWL_API_URL}',
|
||||
jinaApiKey: '${JINA_API_KEY}',
|
||||
safeSearch: 1,
|
||||
serperApiKey: '${SERPER_API_KEY}',
|
||||
searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}',
|
||||
},
|
||||
memory: undefined,
|
||||
agents: {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
const { CacheKeys, EModelEndpoint, orderEndpointsConfig } = require('librechat-data-provider');
|
||||
const {
|
||||
CacheKeys,
|
||||
EModelEndpoint,
|
||||
isAgentsEndpoint,
|
||||
orderEndpointsConfig,
|
||||
defaultAgentCapabilities,
|
||||
} = require('librechat-data-provider');
|
||||
const loadDefaultEndpointsConfig = require('./loadDefaultEConfig');
|
||||
const loadConfigEndpoints = require('./loadConfigEndpoints');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
@@ -80,8 +86,12 @@ async function getEndpointsConfig(req) {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const checkCapability = async (req, capability) => {
|
||||
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
const capabilities = endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [];
|
||||
const capabilities =
|
||||
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null
|
||||
? (endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [])
|
||||
: defaultAgentCapabilities;
|
||||
return capabilities.includes(capability);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { loadServiceKey, isUserProvided } = require('@librechat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { isUserProvided } = require('~/server/utils');
|
||||
const { config } = require('./EndpointService');
|
||||
|
||||
const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } = config;
|
||||
@@ -11,36 +11,29 @@ const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, go
|
||||
* @param {Express.Request} req - The request object
|
||||
*/
|
||||
async function loadAsyncEndpoints(req) {
|
||||
let i = 0;
|
||||
let serviceKey, googleUserProvides;
|
||||
const serviceKeyPath =
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
|
||||
path.join(__dirname, '../../..', 'data', 'auth.json');
|
||||
|
||||
try {
|
||||
if (process.env.GOOGLE_SERVICE_KEY_FILE_PATH) {
|
||||
const absolutePath = path.isAbsolute(serviceKeyPath)
|
||||
? serviceKeyPath
|
||||
: path.resolve(serviceKeyPath);
|
||||
const fileContent = fs.readFileSync(absolutePath, 'utf8');
|
||||
serviceKey = JSON.parse(fileContent);
|
||||
} else {
|
||||
serviceKey = require('~/data/auth.json');
|
||||
}
|
||||
} catch {
|
||||
if (i === 0) {
|
||||
i++;
|
||||
/** Check if GOOGLE_KEY is provided at all(including 'user_provided') */
|
||||
const isGoogleKeyProvided = googleKey && googleKey.trim() !== '';
|
||||
|
||||
if (isGoogleKeyProvided) {
|
||||
/** If GOOGLE_KEY is provided, check if it's user_provided */
|
||||
googleUserProvides = isUserProvided(googleKey);
|
||||
} else {
|
||||
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
||||
const serviceKeyPath =
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
|
||||
path.join(__dirname, '../../..', 'data', 'auth.json');
|
||||
|
||||
try {
|
||||
serviceKey = await loadServiceKey(serviceKeyPath);
|
||||
} catch (error) {
|
||||
logger.error('Error loading service key', error);
|
||||
serviceKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserProvided(googleKey)) {
|
||||
googleUserProvides = true;
|
||||
if (i <= 1) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const google = serviceKey || googleKey ? { userProvide: googleUserProvides } : false;
|
||||
const google = serviceKey || isGoogleKeyProvided ? { userProvide: googleUserProvides } : false;
|
||||
|
||||
const useAzure = req.app.locals[EModelEndpoint.azureOpenAI]?.plugins;
|
||||
const gptPlugins =
|
||||
|
||||
@@ -85,7 +85,7 @@ const initializeAgent = async ({
|
||||
});
|
||||
|
||||
const provider = agent.provider;
|
||||
const { tools, toolContextMap } =
|
||||
const { tools: structuredTools, toolContextMap } =
|
||||
(await loadTools?.({
|
||||
req,
|
||||
res,
|
||||
@@ -140,6 +140,24 @@ const initializeAgent = async ({
|
||||
agent.provider = options.provider;
|
||||
}
|
||||
|
||||
/** @type {import('@librechat/agents').GenericTool[]} */
|
||||
let tools = options.tools?.length ? options.tools : structuredTools;
|
||||
if (
|
||||
(agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) &&
|
||||
options.tools?.length &&
|
||||
structuredTools?.length
|
||||
) {
|
||||
throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
|
||||
} else if (
|
||||
(agent.provider === Providers.OPENAI ||
|
||||
agent.provider === Providers.AZURE ||
|
||||
agent.provider === Providers.ANTHROPIC) &&
|
||||
options.tools?.length &&
|
||||
structuredTools?.length
|
||||
) {
|
||||
tools = structuredTools.concat(options.tools);
|
||||
}
|
||||
|
||||
/** @type {import('@librechat/agents').ClientOptions} */
|
||||
agent.model_parameters = { ...options.llmConfig };
|
||||
if (options.configOptions) {
|
||||
@@ -162,10 +180,10 @@ const initializeAgent = async ({
|
||||
|
||||
return {
|
||||
...agent,
|
||||
tools,
|
||||
attachments,
|
||||
resendFiles,
|
||||
toolContextMap,
|
||||
tools,
|
||||
maxContextTokens: (agentMaxContextTokens - maxTokens) * 0.9,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,7 +78,17 @@ function getLLMConfig(apiKey, options = {}) {
|
||||
requestOptions.anthropicApiUrl = options.reverseProxyUrl;
|
||||
}
|
||||
|
||||
const tools = [];
|
||||
|
||||
if (mergedOptions.web_search) {
|
||||
tools.push({
|
||||
type: 'web_search_20250305',
|
||||
name: 'web_search',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
/** @type {AnthropicClientOptions} */
|
||||
llmConfig: removeNullishValues(requestOptions),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getGoogleConfig, isEnabled } = require('@librechat/api');
|
||||
const { EModelEndpoint, AuthKeys } = require('librechat-data-provider');
|
||||
const { getGoogleConfig, isEnabled, loadServiceKey } = require('@librechat/api');
|
||||
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
const { GoogleClient } = require('~/app');
|
||||
|
||||
@@ -18,21 +17,24 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
||||
|
||||
let serviceKey = {};
|
||||
|
||||
try {
|
||||
if (process.env.GOOGLE_SERVICE_KEY_FILE_PATH) {
|
||||
/** Check if GOOGLE_KEY is provided at all (including 'user_provided') */
|
||||
const isGoogleKeyProvided =
|
||||
(GOOGLE_KEY && GOOGLE_KEY.trim() !== '') || (isUserProvided && userKey != null);
|
||||
|
||||
if (!isGoogleKeyProvided) {
|
||||
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
||||
try {
|
||||
const serviceKeyPath =
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
|
||||
path.join(__dirname, '../../../../..', 'data', 'auth.json');
|
||||
const absolutePath = path.isAbsolute(serviceKeyPath)
|
||||
? serviceKeyPath
|
||||
: path.resolve(serviceKeyPath);
|
||||
const fileContent = fs.readFileSync(absolutePath, 'utf8');
|
||||
serviceKey = JSON.parse(fileContent);
|
||||
} else {
|
||||
serviceKey = require('~/data/auth.json');
|
||||
path.join(__dirname, '../../../..', 'data', 'auth.json');
|
||||
serviceKey = await loadServiceKey(serviceKeyPath);
|
||||
if (!serviceKey) {
|
||||
serviceKey = {};
|
||||
}
|
||||
} catch (_e) {
|
||||
// Service key loading failed, but that's okay if not required
|
||||
serviceKey = {};
|
||||
}
|
||||
} catch (_e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const credentials = isUserProvided
|
||||
|
||||
@@ -7,6 +7,16 @@ const initCustom = require('~/server/services/Endpoints/custom/initialize');
|
||||
const initGoogle = require('~/server/services/Endpoints/google/initialize');
|
||||
const { getCustomEndpointConfig } = require('~/server/services/Config');
|
||||
|
||||
/** Check if the provider is a known custom provider
|
||||
* @param {string | undefined} [provider] - The provider string
|
||||
* @returns {boolean} - True if the provider is a known custom provider, false otherwise
|
||||
*/
|
||||
function isKnownCustomProvider(provider) {
|
||||
return [Providers.XAI, Providers.OLLAMA, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
|
||||
provider?.toLowerCase() || '',
|
||||
);
|
||||
}
|
||||
|
||||
const providerConfigMap = {
|
||||
[Providers.XAI]: initCustom,
|
||||
[Providers.OLLAMA]: initCustom,
|
||||
@@ -46,6 +56,13 @@ async function getProviderConfig(provider) {
|
||||
overrideProvider = Providers.OPENAI;
|
||||
}
|
||||
|
||||
if (isKnownCustomProvider(overrideProvider || provider) && !customEndpointConfig) {
|
||||
customEndpointConfig = await getCustomEndpointConfig(provider);
|
||||
if (!customEndpointConfig) {
|
||||
throw new Error(`Provider ${provider} not supported`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getOptions,
|
||||
overrideProvider,
|
||||
|
||||
280
api/server/utils/import/importers-timestamp.spec.js
Normal file
280
api/server/utils/import/importers-timestamp.spec.js
Normal file
@@ -0,0 +1,280 @@
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const { ImportBatchBuilder } = require('./importBatchBuilder');
|
||||
const { getImporter } = require('./importers');
|
||||
|
||||
// Mock the database methods
|
||||
jest.mock('~/models/Conversation', () => ({
|
||||
bulkSaveConvos: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models/Message', () => ({
|
||||
bulkSaveMessages: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/cache/getLogStores');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const mockedCacheGet = jest.fn();
|
||||
getLogStores.mockImplementation(() => ({
|
||||
get: mockedCacheGet,
|
||||
}));
|
||||
|
||||
describe('Import Timestamp Ordering', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedCacheGet.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('LibreChat Import - Timestamp Issues', () => {
|
||||
test('should maintain proper timestamp order between parent and child messages', async () => {
|
||||
// Create a LibreChat export with out-of-order timestamps
|
||||
const jsonData = {
|
||||
conversationId: 'test-convo-123',
|
||||
title: 'Test Conversation',
|
||||
messages: [
|
||||
{
|
||||
messageId: 'parent-1',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Parent Message',
|
||||
sender: 'user',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
|
||||
},
|
||||
{
|
||||
messageId: 'child-1',
|
||||
parentMessageId: 'parent-1',
|
||||
text: 'Child Message',
|
||||
sender: 'assistant',
|
||||
isCreatedByUser: false,
|
||||
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
|
||||
},
|
||||
{
|
||||
messageId: 'grandchild-1',
|
||||
parentMessageId: 'child-1',
|
||||
text: 'Grandchild Message',
|
||||
sender: 'user',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:00:30Z', // Even earlier
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const requestUserId = 'user-123';
|
||||
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
|
||||
const importer = getImporter(jsonData);
|
||||
await importer(jsonData, requestUserId, () => importBatchBuilder);
|
||||
|
||||
// Check the actual messages stored in the builder
|
||||
const savedMessages = importBatchBuilder.messages;
|
||||
|
||||
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
|
||||
const child = savedMessages.find((msg) => msg.text === 'Child Message');
|
||||
const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
|
||||
|
||||
// Verify all messages were found
|
||||
expect(parent).toBeDefined();
|
||||
expect(child).toBeDefined();
|
||||
expect(grandchild).toBeDefined();
|
||||
|
||||
// FIXED behavior: timestamps ARE corrected
|
||||
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(parent.createdAt).getTime(),
|
||||
);
|
||||
expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(child.createdAt).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle complex multi-branch scenario with out-of-order timestamps', async () => {
|
||||
const jsonData = {
|
||||
conversationId: 'complex-test-123',
|
||||
title: 'Complex Test',
|
||||
messages: [
|
||||
// Branch 1: Root -> A -> B with reversed timestamps
|
||||
{
|
||||
messageId: 'root-1',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Root 1',
|
||||
sender: 'user',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:03:00Z',
|
||||
},
|
||||
{
|
||||
messageId: 'a-1',
|
||||
parentMessageId: 'root-1',
|
||||
text: 'A1',
|
||||
sender: 'assistant',
|
||||
isCreatedByUser: false,
|
||||
createdAt: '2023-01-01T00:02:00Z', // Before parent
|
||||
},
|
||||
{
|
||||
messageId: 'b-1',
|
||||
parentMessageId: 'a-1',
|
||||
text: 'B1',
|
||||
sender: 'user',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:01:00Z', // Before grandparent
|
||||
},
|
||||
// Branch 2: Root -> C -> D with mixed timestamps
|
||||
{
|
||||
messageId: 'root-2',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Root 2',
|
||||
sender: 'user',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:00:30Z', // Earlier than branch 1
|
||||
},
|
||||
{
|
||||
messageId: 'c-2',
|
||||
parentMessageId: 'root-2',
|
||||
text: 'C2',
|
||||
sender: 'assistant',
|
||||
isCreatedByUser: false,
|
||||
createdAt: '2023-01-01T00:04:00Z', // Much later
|
||||
},
|
||||
{
|
||||
messageId: 'd-2',
|
||||
parentMessageId: 'c-2',
|
||||
text: 'D2',
|
||||
sender: 'user',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:02:30Z', // Between root and parent
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const requestUserId = 'user-123';
|
||||
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
|
||||
const importer = getImporter(jsonData);
|
||||
await importer(jsonData, requestUserId, () => importBatchBuilder);
|
||||
|
||||
const savedMessages = importBatchBuilder.messages;
|
||||
|
||||
// Verify that timestamps are preserved as-is (not corrected)
|
||||
const root1 = savedMessages.find((msg) => msg.text === 'Root 1');
|
||||
const a1 = savedMessages.find((msg) => msg.text === 'A1');
|
||||
const b1 = savedMessages.find((msg) => msg.text === 'B1');
|
||||
const root2 = savedMessages.find((msg) => msg.text === 'Root 2');
|
||||
const c2 = savedMessages.find((msg) => msg.text === 'C2');
|
||||
const d2 = savedMessages.find((msg) => msg.text === 'D2');
|
||||
|
||||
// Branch 1: timestamps should now be in correct order
|
||||
expect(new Date(a1.createdAt).getTime()).toBeGreaterThan(new Date(root1.createdAt).getTime());
|
||||
expect(new Date(b1.createdAt).getTime()).toBeGreaterThan(new Date(a1.createdAt).getTime());
|
||||
|
||||
// Branch 2: all timestamps should be properly ordered
|
||||
expect(new Date(c2.createdAt).getTime()).toBeGreaterThan(new Date(root2.createdAt).getTime());
|
||||
expect(new Date(d2.createdAt).getTime()).toBeGreaterThan(new Date(c2.createdAt).getTime());
|
||||
});
|
||||
|
||||
test('recursive format should NOW have timestamp protection', async () => {
|
||||
// Create a recursive LibreChat export with out-of-order timestamps
|
||||
const jsonData = {
|
||||
conversationId: 'recursive-test-123',
|
||||
title: 'Recursive Test',
|
||||
recursive: true,
|
||||
messages: [
|
||||
{
|
||||
messageId: 'parent-1',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Parent Message',
|
||||
sender: 'User',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
|
||||
children: [
|
||||
{
|
||||
messageId: 'child-1',
|
||||
parentMessageId: 'parent-1',
|
||||
text: 'Child Message',
|
||||
sender: 'Assistant',
|
||||
isCreatedByUser: false,
|
||||
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
|
||||
children: [
|
||||
{
|
||||
messageId: 'grandchild-1',
|
||||
parentMessageId: 'child-1',
|
||||
text: 'Grandchild Message',
|
||||
sender: 'User',
|
||||
isCreatedByUser: true,
|
||||
createdAt: '2023-01-01T00:00:30Z', // Even earlier
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const requestUserId = 'user-123';
|
||||
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
|
||||
|
||||
const importer = getImporter(jsonData);
|
||||
await importer(jsonData, requestUserId, () => importBatchBuilder);
|
||||
|
||||
const savedMessages = importBatchBuilder.messages;
|
||||
|
||||
// Messages should be saved
|
||||
expect(savedMessages).toHaveLength(3);
|
||||
|
||||
// In recursive format, timestamps are NOT included in the saved messages
|
||||
// The saveMessage method doesn't receive createdAt for recursive imports
|
||||
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
|
||||
const child = savedMessages.find((msg) => msg.text === 'Child Message');
|
||||
const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
|
||||
|
||||
expect(parent).toBeDefined();
|
||||
expect(child).toBeDefined();
|
||||
expect(grandchild).toBeDefined();
|
||||
|
||||
// Recursive imports NOW preserve and correct timestamps
|
||||
expect(parent.createdAt).toBeDefined();
|
||||
expect(child.createdAt).toBeDefined();
|
||||
expect(grandchild.createdAt).toBeDefined();
|
||||
|
||||
// Timestamps should be corrected to maintain proper order
|
||||
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(parent.createdAt).getTime(),
|
||||
);
|
||||
expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(child.createdAt).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comparison with Fork Functionality', () => {
|
||||
test('fork functionality correctly handles timestamp issues (for comparison)', async () => {
|
||||
const { cloneMessagesWithTimestamps } = require('./fork');
|
||||
|
||||
const messagesToClone = [
|
||||
{
|
||||
messageId: 'parent',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
text: 'Parent Message',
|
||||
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
|
||||
},
|
||||
{
|
||||
messageId: 'child',
|
||||
parentMessageId: 'parent',
|
||||
text: 'Child Message',
|
||||
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
|
||||
},
|
||||
];
|
||||
|
||||
const importBatchBuilder = new ImportBatchBuilder('user-123');
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
|
||||
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
|
||||
|
||||
const savedMessages = importBatchBuilder.messages;
|
||||
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
|
||||
const child = savedMessages.find((msg) => msg.text === 'Child Message');
|
||||
|
||||
// Fork functionality DOES correct the timestamps
|
||||
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(parent.createdAt).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { EModelEndpoint, Constants, openAISettings, CacheKeys } = require('librechat-data-provider');
|
||||
const { createImportBatchBuilder } = require('./importBatchBuilder');
|
||||
const { cloneMessagesWithTimestamps } = require('./fork');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const logger = require('~/config/winston');
|
||||
|
||||
@@ -107,67 +108,47 @@ async function importLibreChatConvo(
|
||||
|
||||
if (jsonData.recursive) {
|
||||
/**
|
||||
* Recursively traverse the messages tree and save each message to the database.
|
||||
* Flatten the recursive message tree into a flat array
|
||||
* @param {TMessage[]} messages
|
||||
* @param {string} parentMessageId
|
||||
* @param {TMessage[]} flatMessages
|
||||
*/
|
||||
const traverseMessages = async (messages, parentMessageId = null) => {
|
||||
const flattenMessages = (
|
||||
messages,
|
||||
parentMessageId = Constants.NO_PARENT,
|
||||
flatMessages = [],
|
||||
) => {
|
||||
for (const message of messages) {
|
||||
if (!message.text && !message.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let savedMessage;
|
||||
if (message.sender?.toLowerCase() === 'user' || message.isCreatedByUser) {
|
||||
savedMessage = await importBatchBuilder.saveMessage({
|
||||
text: message.text,
|
||||
content: message.content,
|
||||
sender: 'user',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: parentMessageId,
|
||||
});
|
||||
} else {
|
||||
savedMessage = await importBatchBuilder.saveMessage({
|
||||
text: message.text,
|
||||
content: message.content,
|
||||
sender: message.sender,
|
||||
isCreatedByUser: false,
|
||||
model: options.model,
|
||||
parentMessageId: parentMessageId,
|
||||
});
|
||||
}
|
||||
const flatMessage = {
|
||||
...message,
|
||||
parentMessageId: parentMessageId,
|
||||
children: undefined, // Remove children from flat structure
|
||||
};
|
||||
flatMessages.push(flatMessage);
|
||||
|
||||
if (!firstMessageDate && message.createdAt) {
|
||||
firstMessageDate = new Date(message.createdAt);
|
||||
}
|
||||
|
||||
if (message.children && message.children.length > 0) {
|
||||
await traverseMessages(message.children, savedMessage.messageId);
|
||||
flattenMessages(message.children, message.messageId, flatMessages);
|
||||
}
|
||||
}
|
||||
return flatMessages;
|
||||
};
|
||||
|
||||
await traverseMessages(messagesToImport);
|
||||
const flatMessages = flattenMessages(messagesToImport);
|
||||
cloneMessagesWithTimestamps(flatMessages, importBatchBuilder);
|
||||
} else if (messagesToImport) {
|
||||
const idMapping = new Map();
|
||||
|
||||
cloneMessagesWithTimestamps(messagesToImport, importBatchBuilder);
|
||||
for (const message of messagesToImport) {
|
||||
if (!firstMessageDate && message.createdAt) {
|
||||
firstMessageDate = new Date(message.createdAt);
|
||||
}
|
||||
const newMessageId = uuidv4();
|
||||
idMapping.set(message.messageId, newMessageId);
|
||||
|
||||
const clonedMessage = {
|
||||
...message,
|
||||
messageId: newMessageId,
|
||||
parentMessageId:
|
||||
message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT
|
||||
? idMapping.get(message.parentMessageId) || Constants.NO_PARENT
|
||||
: Constants.NO_PARENT,
|
||||
};
|
||||
|
||||
importBatchBuilder.saveMessage(clonedMessage);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid LibreChat file format');
|
||||
|
||||
@@ -175,36 +175,60 @@ describe('importLibreChatConvo', () => {
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
jest.spyOn(importBatchBuilder, 'saveBatch');
|
||||
|
||||
// When
|
||||
const importer = getImporter(jsonData);
|
||||
await importer(jsonData, requestUserId, () => importBatchBuilder);
|
||||
|
||||
// Create a map to track original message IDs to new UUIDs
|
||||
const idToUUIDMap = new Map();
|
||||
importBatchBuilder.saveMessage.mock.calls.forEach((call) => {
|
||||
const message = call[0];
|
||||
idToUUIDMap.set(message.originalMessageId, message.messageId);
|
||||
// Get the imported messages
|
||||
const messages = importBatchBuilder.messages;
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Build maps for verification
|
||||
const textToMessageMap = new Map();
|
||||
const messageIdToMessage = new Map();
|
||||
messages.forEach((msg) => {
|
||||
if (msg.text) {
|
||||
// For recursive imports, text might be very long, so just use the first 100 chars as key
|
||||
const textKey = msg.text.substring(0, 100);
|
||||
textToMessageMap.set(textKey, msg);
|
||||
}
|
||||
messageIdToMessage.set(msg.messageId, msg);
|
||||
});
|
||||
|
||||
const checkChildren = (children, parentId) => {
|
||||
children.forEach((child) => {
|
||||
const childUUID = idToUUIDMap.get(child.messageId);
|
||||
const expectedParentId = idToUUIDMap.get(parentId) ?? null;
|
||||
const messageCall = importBatchBuilder.saveMessage.mock.calls.find(
|
||||
(call) => call[0].messageId === childUUID,
|
||||
);
|
||||
|
||||
const actualParentId = messageCall[0].parentMessageId;
|
||||
expect(actualParentId).toBe(expectedParentId);
|
||||
|
||||
if (child.children && child.children.length > 0) {
|
||||
checkChildren(child.children, child.messageId);
|
||||
// Count expected messages from the tree
|
||||
const countMessagesInTree = (nodes) => {
|
||||
let count = 0;
|
||||
nodes.forEach((node) => {
|
||||
if (node.text || node.content) {
|
||||
count++;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
count += countMessagesInTree(node.children);
|
||||
}
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
// Start hierarchy validation from root messages
|
||||
checkChildren(jsonData.messages, null);
|
||||
const expectedMessageCount = countMessagesInTree(jsonData.messages);
|
||||
expect(messages.length).toBe(expectedMessageCount);
|
||||
|
||||
// Verify all messages have valid parent relationships
|
||||
messages.forEach((msg) => {
|
||||
if (msg.parentMessageId !== Constants.NO_PARENT) {
|
||||
const parent = messageIdToMessage.get(msg.parentMessageId);
|
||||
expect(parent).toBeDefined();
|
||||
|
||||
// Verify timestamp ordering
|
||||
if (msg.createdAt && parent.createdAt) {
|
||||
expect(new Date(msg.createdAt).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(parent.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Verify at least one root message exists
|
||||
const rootMessages = messages.filter((msg) => msg.parentMessageId === Constants.NO_PARENT);
|
||||
expect(rootMessages.length).toBeGreaterThan(0);
|
||||
|
||||
expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const { updateUser, findUser } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
@@ -13,17 +14,23 @@ const { isEnabled } = require('~/server/utils');
|
||||
* The strategy extracts the JWT from the Authorization header as a Bearer token.
|
||||
* The JWT is then verified using the signing key, and the user is retrieved from the database.
|
||||
*/
|
||||
const openIdJwtLogin = (openIdConfig) =>
|
||||
new JwtStrategy(
|
||||
const openIdJwtLogin = (openIdConfig) => {
|
||||
let jwksRsaOptions = {
|
||||
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
|
||||
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME
|
||||
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
|
||||
: 60000,
|
||||
jwksUri: openIdConfig.serverMetadata().jwks_uri,
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
jwksRsaOptions.requestAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
return new JwtStrategy(
|
||||
{
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
|
||||
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME
|
||||
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
|
||||
: 60000,
|
||||
jwksUri: openIdConfig.serverMetadata().jwks_uri,
|
||||
}),
|
||||
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
@@ -48,5 +55,6 @@ const openIdJwtLogin = (openIdConfig) =>
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = openIdJwtLogin;
|
||||
|
||||
@@ -49,7 +49,7 @@ async function customFetch(url, options) {
|
||||
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
|
||||
fetchOptions = {
|
||||
...options,
|
||||
dispatcher: new HttpsProxyAgent(process.env.PROXY),
|
||||
dispatcher: new undici.ProxyAgent(process.env.PROXY),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.7.8",
|
||||
"version": "v0.7.9-rc1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TPlugin, AgentToolType, Action, MCP } from 'librechat-data-provider';
|
||||
import type { MCP, Action, TPlugin, AgentToolType } from 'librechat-data-provider';
|
||||
import type { AgentPanelContextType } from '~/common';
|
||||
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useLocalize, useGetAgentsConfig } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
||||
@@ -75,21 +75,25 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
|
||||
);
|
||||
|
||||
const value = {
|
||||
action,
|
||||
setAction,
|
||||
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
|
||||
|
||||
const value: AgentPanelContextType = {
|
||||
mcp,
|
||||
setMcp,
|
||||
mcps,
|
||||
setMcps,
|
||||
activePanel,
|
||||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
agent_id,
|
||||
groupedTools,
|
||||
/** Query data for actions and tools */
|
||||
actions,
|
||||
tools,
|
||||
action,
|
||||
setMcp,
|
||||
actions,
|
||||
setMcps,
|
||||
agent_id,
|
||||
setAction,
|
||||
activePanel,
|
||||
groupedTools,
|
||||
agentsConfig,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
setCurrentAgentId,
|
||||
};
|
||||
|
||||
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { Tools, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { useMCPSelect, useToolToggle, useCodeApiKeyForm, useSearchApiKeyForm } from '~/hooks';
|
||||
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useSearchApiKeyForm,
|
||||
useGetAgentsConfig,
|
||||
useCodeApiKeyForm,
|
||||
useToolToggle,
|
||||
useMCPSelect,
|
||||
} from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
interface BadgeRowContextType {
|
||||
conversationId?: string | null;
|
||||
agentsConfig?: TAgentsEndpoint | null;
|
||||
mcpSelect: ReturnType<typeof useMCPSelect>;
|
||||
webSearch: ReturnType<typeof useToolToggle>;
|
||||
codeInterpreter: ReturnType<typeof useToolToggle>;
|
||||
artifacts: ReturnType<typeof useToolToggle>;
|
||||
fileSearch: ReturnType<typeof useToolToggle>;
|
||||
codeInterpreter: ReturnType<typeof useToolToggle>;
|
||||
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
|
||||
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
|
||||
startupConfig: ReturnType<typeof useGetStartupConfig>['data'];
|
||||
@@ -26,10 +37,88 @@ export function useBadgeRowContext() {
|
||||
|
||||
interface BadgeRowProviderProps {
|
||||
children: React.ReactNode;
|
||||
isSubmitting?: boolean;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
export default function BadgeRowProvider({ children, conversationId }: BadgeRowProviderProps) {
|
||||
export default function BadgeRowProvider({
|
||||
children,
|
||||
isSubmitting,
|
||||
conversationId,
|
||||
}: BadgeRowProviderProps) {
|
||||
const hasInitializedRef = useRef(false);
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(key));
|
||||
|
||||
/** Initialize ephemeralAgent from localStorage on mount and when conversation changes */
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
// Check if this is a new conversation or the first load
|
||||
if (!hasInitializedRef.current || lastKeyRef.current !== key) {
|
||||
hasInitializedRef.current = true;
|
||||
lastKeyRef.current = key;
|
||||
|
||||
// Load all localStorage values
|
||||
const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`;
|
||||
const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`;
|
||||
const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`;
|
||||
const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`;
|
||||
|
||||
const codeToggleValue = localStorage.getItem(codeToggleKey);
|
||||
const webSearchToggleValue = localStorage.getItem(webSearchToggleKey);
|
||||
const fileSearchToggleValue = localStorage.getItem(fileSearchToggleKey);
|
||||
const artifactsToggleValue = localStorage.getItem(artifactsToggleKey);
|
||||
|
||||
const initialValues: Record<string, any> = {};
|
||||
|
||||
if (codeToggleValue !== null) {
|
||||
try {
|
||||
initialValues[Tools.execute_code] = JSON.parse(codeToggleValue);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse code toggle value:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (webSearchToggleValue !== null) {
|
||||
try {
|
||||
initialValues[Tools.web_search] = JSON.parse(webSearchToggleValue);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse web search toggle value:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileSearchToggleValue !== null) {
|
||||
try {
|
||||
initialValues[Tools.file_search] = JSON.parse(fileSearchToggleValue);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse file search toggle value:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (artifactsToggleValue !== null) {
|
||||
try {
|
||||
initialValues[AgentCapabilities.artifacts] = JSON.parse(artifactsToggleValue);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse artifacts toggle value:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Always set values for all tools (use defaults if not in localStorage)
|
||||
// If ephemeralAgent is null, create a new object with just our tool values
|
||||
setEphemeralAgent((prev) => ({
|
||||
...(prev || {}),
|
||||
[Tools.execute_code]: initialValues[Tools.execute_code] ?? false,
|
||||
[Tools.web_search]: initialValues[Tools.web_search] ?? false,
|
||||
[Tools.file_search]: initialValues[Tools.file_search] ?? false,
|
||||
[AgentCapabilities.artifacts]: initialValues[AgentCapabilities.artifacts] ?? false,
|
||||
}));
|
||||
}
|
||||
}, [key, isSubmitting, setEphemeralAgent]);
|
||||
|
||||
/** Startup config */
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
@@ -74,10 +163,20 @@ export default function BadgeRowProvider({ children, conversationId }: BadgeRowP
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
/** Artifacts hook - using a custom key since it's not a Tool but a capability */
|
||||
const artifacts = useToolToggle({
|
||||
conversationId,
|
||||
toolKey: AgentCapabilities.artifacts,
|
||||
localStorageKey: LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
const value: BadgeRowContextType = {
|
||||
mcpSelect,
|
||||
webSearch,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import {
|
||||
AuthorizationTypeEnum,
|
||||
AuthTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import { MCPForm } from '~/common/types';
|
||||
|
||||
export const defaultMCPFormValues: MCPForm = {
|
||||
type: AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
api_key: '',
|
||||
authorization_type: AuthorizationTypeEnum.Basic,
|
||||
custom_auth_header: '',
|
||||
oauth_client_id: '',
|
||||
oauth_client_secret: '',
|
||||
authorization_url: '',
|
||||
client_url: '',
|
||||
scope: '',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
name: '',
|
||||
description: '',
|
||||
url: '',
|
||||
tools: [],
|
||||
icon: '',
|
||||
trust: false,
|
||||
customHeaders: [],
|
||||
requestTimeout: undefined,
|
||||
connectionTimeout: undefined,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RefObject } from 'react';
|
||||
import { FileSources, EModelEndpoint, TPlugin } from 'librechat-data-provider';
|
||||
import { FileSources, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type * as InputNumberPrimitive from 'rc-input-number';
|
||||
import type { SetterOrUpdater, RecoilState } from 'recoil';
|
||||
@@ -167,27 +167,13 @@ export type ActionAuthForm = {
|
||||
token_exchange_method: t.TokenExchangeMethodEnum;
|
||||
};
|
||||
|
||||
export type MCPForm = MCPMetadata;
|
||||
|
||||
export type MCP = {
|
||||
mcp_id: string;
|
||||
metadata: MCPMetadata;
|
||||
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
|
||||
|
||||
export type MCPMetadata = {
|
||||
name: string;
|
||||
export type MCPForm = ActionAuthForm & {
|
||||
name?: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
tools?: TPlugin[];
|
||||
url?: string;
|
||||
tools?: string[];
|
||||
icon?: string;
|
||||
trust?: boolean;
|
||||
customHeaders?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
requestTimeout?: number;
|
||||
connectionTimeout?: number;
|
||||
};
|
||||
|
||||
export type ActionWithNullableMetadata = Omit<t.Action, 'metadata'> & {
|
||||
@@ -220,9 +206,7 @@ export type AgentPanelProps = {
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
|
||||
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
|
||||
endpointsConfig?: t.TEndpointsConfig;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
agentsConfig?: t.TAgentsEndpoint | null;
|
||||
};
|
||||
|
||||
export type AgentPanelContextType = {
|
||||
@@ -239,6 +223,8 @@ export type AgentPanelContextType = {
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
groupedTools?: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
|
||||
agent_id?: string;
|
||||
agentsConfig?: t.TAgentsEndpoint | null;
|
||||
endpointsConfig?: t.TEndpointsConfig | null;
|
||||
};
|
||||
|
||||
export type AgentModelPanelProps = {
|
||||
@@ -350,6 +336,11 @@ export type TAskProps = {
|
||||
export type TOptions = {
|
||||
editedMessageId?: string | null;
|
||||
editedText?: string | null;
|
||||
editedContent?: {
|
||||
index: number;
|
||||
text: string;
|
||||
type: 'text' | 'think';
|
||||
};
|
||||
isRegenerate?: boolean;
|
||||
isContinued?: boolean;
|
||||
isEdited?: boolean;
|
||||
|
||||
152
client/src/components/Chat/Input/Artifacts.tsx
Normal file
152
client/src/components/Chat/Input/Artifacts.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { memo, useState, useCallback, useMemo } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ArtifactModes } from 'librechat-data-provider';
|
||||
import { WandSparkles, ChevronDown } from 'lucide-react';
|
||||
import CheckboxButton from '~/components/ui/CheckboxButton';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ArtifactsToggleState {
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
function Artifacts() {
|
||||
const localize = useLocalize();
|
||||
const { artifacts } = useBadgeRowContext();
|
||||
const { toggleState, debouncedChange, isPinned } = artifacts;
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const currentState = useMemo<ArtifactsToggleState>(() => {
|
||||
if (typeof toggleState === 'string' && toggleState) {
|
||||
return { enabled: true, mode: toggleState };
|
||||
}
|
||||
return { enabled: false, mode: '' };
|
||||
}, [toggleState]);
|
||||
|
||||
const isEnabled = currentState.enabled;
|
||||
const isShadcnEnabled = currentState.mode === ArtifactModes.SHADCNUI;
|
||||
const isCustomEnabled = currentState.mode === ArtifactModes.CUSTOM;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isEnabled) {
|
||||
debouncedChange({ value: '' });
|
||||
} else {
|
||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
}
|
||||
}, [isEnabled, debouncedChange]);
|
||||
|
||||
const handleShadcnToggle = useCallback(() => {
|
||||
if (isShadcnEnabled) {
|
||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
} else {
|
||||
debouncedChange({ value: ArtifactModes.SHADCNUI });
|
||||
}
|
||||
}, [isShadcnEnabled, debouncedChange]);
|
||||
|
||||
const handleCustomToggle = useCallback(() => {
|
||||
if (isCustomEnabled) {
|
||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
} else {
|
||||
debouncedChange({ value: ArtifactModes.CUSTOM });
|
||||
}
|
||||
}, [isCustomEnabled, debouncedChange]);
|
||||
|
||||
if (!isEnabled && !isPinned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<CheckboxButton
|
||||
className={cn('max-w-fit', isEnabled && 'rounded-r-none border-r-0')}
|
||||
checked={isEnabled}
|
||||
setValue={handleToggle}
|
||||
label={localize('com_ui_artifacts')}
|
||||
isCheckedClassName="border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10"
|
||||
icon={<WandSparkles className="icon-md" />}
|
||||
/>
|
||||
|
||||
{isEnabled && (
|
||||
<Ariakit.MenuProvider open={isPopoverOpen} setOpen={setIsPopoverOpen}>
|
||||
<Ariakit.MenuButton
|
||||
className={cn(
|
||||
'w-7 rounded-l-none rounded-r-full border-b border-l-0 border-r border-t border-border-light md:w-6',
|
||||
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
||||
'transition-colors',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown className="ml-1 h-4 w-4 text-text-secondary md:ml-0" />
|
||||
</Ariakit.MenuButton>
|
||||
|
||||
<Ariakit.Menu
|
||||
gutter={8}
|
||||
className={cn(
|
||||
'animate-popover z-50 flex max-h-[300px]',
|
||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
||||
'border border-border-light',
|
||||
'min-w-[250px] outline-none',
|
||||
)}
|
||||
portal
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="mb-2 text-xs font-medium text-text-secondary">
|
||||
{localize('com_ui_artifacts_options')}
|
||||
</div>
|
||||
|
||||
{/* Include shadcn/ui Option */}
|
||||
<Ariakit.MenuItem
|
||||
hideOnClick={false}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
{/* Custom Prompt Mode Option */}
|
||||
<Ariakit.MenuItem
|
||||
hideOnClick={false}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
</Ariakit.Menu>
|
||||
</Ariakit.MenuProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Artifacts);
|
||||
147
client/src/components/Chat/Input/ArtifactsSubMenu.tsx
Normal file
147
client/src/components/Chat/Input/ArtifactsSubMenu.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ChevronRight, WandSparkles } from 'lucide-react';
|
||||
import { ArtifactModes } from 'librechat-data-provider';
|
||||
import { PinIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ArtifactsSubMenuProps {
|
||||
isArtifactsPinned: boolean;
|
||||
setIsArtifactsPinned: (value: boolean) => void;
|
||||
artifactsMode: string;
|
||||
handleArtifactsToggle: () => void;
|
||||
handleShadcnToggle: () => void;
|
||||
handleCustomToggle: () => void;
|
||||
}
|
||||
|
||||
const ArtifactsSubMenu = ({
|
||||
isArtifactsPinned,
|
||||
setIsArtifactsPinned,
|
||||
artifactsMode,
|
||||
handleArtifactsToggle,
|
||||
handleShadcnToggle,
|
||||
handleCustomToggle,
|
||||
...props
|
||||
}: ArtifactsSubMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
const menuStore = Ariakit.useMenuStore({
|
||||
focusLoop: true,
|
||||
showTimeout: 100,
|
||||
placement: 'right',
|
||||
});
|
||||
|
||||
const isEnabled = artifactsMode !== '' && artifactsMode !== undefined;
|
||||
const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI;
|
||||
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
|
||||
|
||||
return (
|
||||
<Ariakit.MenuProvider store={menuStore}>
|
||||
<Ariakit.MenuItem
|
||||
{...props}
|
||||
hideOnClick={false}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
handleArtifactsToggle();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (isEnabled) {
|
||||
menuStore.show();
|
||||
}
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WandSparkles className="icon-md" />
|
||||
<span>{localize('com_ui_artifacts')}</span>
|
||||
{isEnabled && <ChevronRight className="ml-auto h-3 w-3" />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsArtifactsPinned(!isArtifactsPinned);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-tertiary hover:shadow-sm',
|
||||
!isArtifactsPinned && 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isArtifactsPinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isArtifactsPinned} />
|
||||
</div>
|
||||
</button>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
{isEnabled && (
|
||||
<Ariakit.Menu
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
className={cn(
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="mb-2 text-xs font-medium text-text-secondary">
|
||||
{localize('com_ui_artifacts_options')}
|
||||
</div>
|
||||
|
||||
{/* Include shadcn/ui Option */}
|
||||
<Ariakit.MenuItem
|
||||
hideOnClick={false}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
{/* Custom Prompt Mode Option */}
|
||||
<Ariakit.MenuItem
|
||||
hideOnClick={false}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
</Ariakit.Menu>
|
||||
)}
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ArtifactsSubMenu);
|
||||
@@ -18,6 +18,7 @@ import { useChatBadges } from '~/hooks';
|
||||
import { Badge } from '~/components/ui';
|
||||
import ToolDialogs from './ToolDialogs';
|
||||
import FileSearch from './FileSearch';
|
||||
import Artifacts from './Artifacts';
|
||||
import MCPSelect from './MCPSelect';
|
||||
import WebSearch from './WebSearch';
|
||||
import store from '~/store';
|
||||
@@ -27,6 +28,7 @@ interface BadgeRowProps {
|
||||
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
|
||||
onToggle?: (badgeId: string, currentActive: boolean) => void;
|
||||
conversationId?: string | null;
|
||||
isSubmitting?: boolean;
|
||||
isInChat: boolean;
|
||||
}
|
||||
|
||||
@@ -140,6 +142,7 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
|
||||
function BadgeRow({
|
||||
showEphemeralBadges,
|
||||
conversationId,
|
||||
isSubmitting,
|
||||
onChange,
|
||||
onToggle,
|
||||
isInChat,
|
||||
@@ -317,7 +320,7 @@ function BadgeRow({
|
||||
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<BadgeRowProvider conversationId={conversationId}>
|
||||
<BadgeRowProvider conversationId={conversationId} isSubmitting={isSubmitting}>
|
||||
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
|
||||
{showEphemeralBadges === true && <ToolsDropdown />}
|
||||
{tempBadges.map((badge, index) => (
|
||||
@@ -364,6 +367,7 @@ function BadgeRow({
|
||||
<WebSearch />
|
||||
<CodeInterpreter />
|
||||
<FileSearch />
|
||||
<Artifacts />
|
||||
<MCPSelect />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -305,6 +305,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
</div>
|
||||
<BadgeRow
|
||||
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||
isSubmitting={isSubmitting || isSubmittingAdded}
|
||||
conversationId={conversationId}
|
||||
onChange={setBadges}
|
||||
isInChat={
|
||||
|
||||
@@ -4,18 +4,21 @@ import {
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
isAssistantsEndpoint,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import AttachFile from './AttachFile';
|
||||
|
||||
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const { conversation } = useChatContext();
|
||||
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
|
||||
const { endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
|
||||
const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]);
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
@@ -25,7 +28,9 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
||||
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
|
||||
|
||||
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
|
||||
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
|
||||
return <AttachFile disabled={disableInputs} />;
|
||||
} else if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
|
||||
return (
|
||||
<AttachFileMenu
|
||||
disabled={disableInputs}
|
||||
@@ -34,7 +39,6 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useSetRecoilState } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import { useLocalize, useGetAgentsConfig, useFileHandling, useAgentCapabilities } from '~/hooks';
|
||||
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
|
||||
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useLocalize, useFileHandling } from '~/hooks';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
@@ -23,20 +22,17 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
|
||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
overrideEndpointFileConfig: endpointFileConfig,
|
||||
});
|
||||
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
/** TODO: Ephemeral Agent Capabilities
|
||||
* Allow defining agent capabilities on a per-endpoint basis
|
||||
* Use definition for agents endpoint for ephemeral agents
|
||||
* */
|
||||
const capabilities = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
||||
[endpointsConfig],
|
||||
);
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
const handleUploadClick = (isImage?: boolean) => {
|
||||
if (!inputRef.current) {
|
||||
@@ -60,7 +56,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
},
|
||||
];
|
||||
|
||||
if (capabilities.includes(EToolResources.ocr)) {
|
||||
if (capabilities.ocrEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
onClick: () => {
|
||||
@@ -71,7 +67,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.includes(EToolResources.file_search)) {
|
||||
if (capabilities.fileSearchEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_file_search'),
|
||||
onClick: () => {
|
||||
@@ -83,7 +79,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.includes(EToolResources.execute_code)) {
|
||||
if (capabilities.codeEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_code_files'),
|
||||
onClick: () => {
|
||||
|
||||
@@ -2,11 +2,18 @@ import React, { useState, useMemo, useCallback } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Globe, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
|
||||
import type { MenuItemProps } from '~/common';
|
||||
import { Permissions, PermissionTypes, AuthType } from 'librechat-data-provider';
|
||||
import {
|
||||
AuthType,
|
||||
Permissions,
|
||||
ArtifactModes,
|
||||
PermissionTypes,
|
||||
defaultAgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import { TooltipAnchor, DropdownPopup } from '~/components';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
|
||||
import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu';
|
||||
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
|
||||
import { PinIcon, VectorIcon } from '~/components/svg';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
@@ -21,12 +28,17 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
const {
|
||||
webSearch,
|
||||
mcpSelect,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
} = useBadgeRowContext();
|
||||
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
|
||||
useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
|
||||
codeApiKeyForm;
|
||||
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
|
||||
@@ -42,6 +54,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
authData: codeAuthData,
|
||||
} = codeInterpreter;
|
||||
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
|
||||
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts;
|
||||
const {
|
||||
mcpValues,
|
||||
mcpServerNames,
|
||||
@@ -72,19 +85,46 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
|
||||
const handleWebSearchToggle = useCallback(() => {
|
||||
const newValue = !webSearch.toggleState;
|
||||
webSearch.debouncedChange({ isChecked: newValue });
|
||||
webSearch.debouncedChange({ value: newValue });
|
||||
}, [webSearch]);
|
||||
|
||||
const handleCodeInterpreterToggle = useCallback(() => {
|
||||
const newValue = !codeInterpreter.toggleState;
|
||||
codeInterpreter.debouncedChange({ isChecked: newValue });
|
||||
codeInterpreter.debouncedChange({ value: newValue });
|
||||
}, [codeInterpreter]);
|
||||
|
||||
const handleFileSearchToggle = useCallback(() => {
|
||||
const newValue = !fileSearch.toggleState;
|
||||
fileSearch.debouncedChange({ isChecked: newValue });
|
||||
fileSearch.debouncedChange({ value: newValue });
|
||||
}, [fileSearch]);
|
||||
|
||||
const handleArtifactsToggle = useCallback(() => {
|
||||
const currentState = artifacts.toggleState;
|
||||
if (!currentState || currentState === '') {
|
||||
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
} else {
|
||||
artifacts.debouncedChange({ value: '' });
|
||||
}
|
||||
}, [artifacts]);
|
||||
|
||||
const handleShadcnToggle = useCallback(() => {
|
||||
const currentState = artifacts.toggleState;
|
||||
if (currentState === ArtifactModes.SHADCNUI) {
|
||||
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
} else {
|
||||
artifacts.debouncedChange({ value: ArtifactModes.SHADCNUI });
|
||||
}
|
||||
}, [artifacts]);
|
||||
|
||||
const handleCustomToggle = useCallback(() => {
|
||||
const currentState = artifacts.toggleState;
|
||||
if (currentState === ArtifactModes.CUSTOM) {
|
||||
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
} else {
|
||||
artifacts.debouncedChange({ value: ArtifactModes.CUSTOM });
|
||||
}
|
||||
}, [artifacts]);
|
||||
|
||||
const handleMCPToggle = useCallback(
|
||||
(serverName: string) => {
|
||||
const currentValues = mcpSelect.mcpValues ?? [];
|
||||
@@ -98,9 +138,10 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
|
||||
const mcpPlaceholder = startupConfig?.interface?.mcpServers?.placeholder;
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const items: MenuItemProps[] = [];
|
||||
items.push({
|
||||
const dropdownItems: MenuItemProps[] = [];
|
||||
|
||||
if (fileSearchEnabled) {
|
||||
dropdownItems.push({
|
||||
onClick: handleFileSearchToggle,
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
@@ -129,159 +170,149 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (canUseWebSearch) {
|
||||
items.push({
|
||||
onClick: handleWebSearchToggle,
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<div {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="icon-md" />
|
||||
<span>{localize('com_ui_web_search')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showWebSearchSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSearchDialogOpen(true);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label="Configure web search"
|
||||
ref={searchMenuTriggerRef}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<Settings className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
if (canUseWebSearch && webSearchEnabled) {
|
||||
dropdownItems.push({
|
||||
onClick: handleWebSearchToggle,
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<div {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="icon-md" />
|
||||
<span>{localize('com_ui_web_search')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showWebSearchSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSearchPinned(!isSearchPinned);
|
||||
setIsSearchDialogOpen(true);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
!isSearchPinned && 'text-text-secondary hover:text-text-primary',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isSearchPinned ? 'Unpin' : 'Pin'}
|
||||
aria-label="Configure web search"
|
||||
ref={searchMenuTriggerRef}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isSearchPinned} />
|
||||
<Settings className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (canRunCode) {
|
||||
items.push({
|
||||
onClick: handleCodeInterpreterToggle,
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<div {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquareIcon className="icon-md" />
|
||||
<span>{localize('com_assistants_code_interpreter')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showCodeSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCodeDialogOpen(true);
|
||||
}}
|
||||
ref={codeMenuTriggerRef}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label="Configure code interpreter"
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<Settings className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSearchPinned(!isSearchPinned);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
!isSearchPinned && 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isSearchPinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isSearchPinned} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (canRunCode && codeEnabled) {
|
||||
dropdownItems.push({
|
||||
onClick: handleCodeInterpreterToggle,
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<div {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquareIcon className="icon-md" />
|
||||
<span>{localize('com_assistants_code_interpreter')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showCodeSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCodePinned(!isCodePinned);
|
||||
setIsCodeDialogOpen(true);
|
||||
}}
|
||||
ref={codeMenuTriggerRef}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
!isCodePinned && 'text-text-primary hover:text-text-primary',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isCodePinned ? 'Unpin' : 'Pin'}
|
||||
aria-label="Configure code interpreter"
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isCodePinned} />
|
||||
<Settings className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCodePinned(!isCodePinned);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
!isCodePinned && 'text-text-primary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isCodePinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isCodePinned} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (mcpServerNames && mcpServerNames.length > 0) {
|
||||
items.push({
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<MCPSubMenu
|
||||
{...props}
|
||||
mcpValues={mcpValues}
|
||||
isMCPPinned={isMCPPinned}
|
||||
placeholder={mcpPlaceholder}
|
||||
mcpServerNames={mcpServerNames}
|
||||
setIsMCPPinned={setIsMCPPinned}
|
||||
handleMCPToggle={handleMCPToggle}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (artifactsEnabled) {
|
||||
dropdownItems.push({
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<ArtifactsSubMenu
|
||||
{...props}
|
||||
isArtifactsPinned={isArtifactsPinned}
|
||||
setIsArtifactsPinned={setIsArtifactsPinned}
|
||||
artifactsMode={artifacts.toggleState as string}
|
||||
handleArtifactsToggle={handleArtifactsToggle}
|
||||
handleShadcnToggle={handleShadcnToggle}
|
||||
handleCustomToggle={handleCustomToggle}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
localize,
|
||||
mcpValues,
|
||||
canRunCode,
|
||||
isMCPPinned,
|
||||
isCodePinned,
|
||||
mcpPlaceholder,
|
||||
mcpServerNames,
|
||||
isSearchPinned,
|
||||
setIsMCPPinned,
|
||||
canUseWebSearch,
|
||||
setIsCodePinned,
|
||||
handleMCPToggle,
|
||||
showCodeSettings,
|
||||
setIsSearchPinned,
|
||||
isFileSearchPinned,
|
||||
codeMenuTriggerRef,
|
||||
setIsCodeDialogOpen,
|
||||
searchMenuTriggerRef,
|
||||
showWebSearchSettings,
|
||||
setIsFileSearchPinned,
|
||||
handleWebSearchToggle,
|
||||
setIsSearchDialogOpen,
|
||||
handleFileSearchToggle,
|
||||
handleCodeInterpreterToggle,
|
||||
]);
|
||||
if (mcpServerNames && mcpServerNames.length > 0) {
|
||||
dropdownItems.push({
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<MCPSubMenu
|
||||
{...props}
|
||||
mcpValues={mcpValues}
|
||||
isMCPPinned={isMCPPinned}
|
||||
placeholder={mcpPlaceholder}
|
||||
mcpServerNames={mcpServerNames}
|
||||
setIsMCPPinned={setIsMCPPinned}
|
||||
handleMCPToggle={handleMCPToggle}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const menuTrigger = (
|
||||
<TooltipAnchor
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useBadgeRowContext } from '~/Providers';
|
||||
function WebSearch() {
|
||||
const localize = useLocalize();
|
||||
const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext();
|
||||
const { toggleState: webSearch, debouncedChange, isPinned } = webSearchData;
|
||||
const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData;
|
||||
const { badgeTriggerRef } = searchApiKeyForm;
|
||||
|
||||
const canUseWebSearch = useHasAccess({
|
||||
@@ -21,7 +21,7 @@ function WebSearch() {
|
||||
}
|
||||
|
||||
return (
|
||||
(webSearch || isPinned) && (
|
||||
(isPinned || (webSearch && authData?.authenticated)) && (
|
||||
<CheckboxButton
|
||||
ref={badgeTriggerRef}
|
||||
className="max-w-fit"
|
||||
|
||||
@@ -81,14 +81,23 @@ const ContentParts = memo(
|
||||
return (
|
||||
<>
|
||||
{content.map((part, idx) => {
|
||||
if (part?.type !== ContentTypes.TEXT || typeof part.text !== 'string') {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
const isTextPart =
|
||||
part?.type === ContentTypes.TEXT ||
|
||||
typeof (part as unknown as Agents.MessageContentText)?.text !== 'string';
|
||||
const isThinkPart =
|
||||
part?.type === ContentTypes.THINK ||
|
||||
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string';
|
||||
if (!isTextPart && !isThinkPart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditTextPart
|
||||
index={idx}
|
||||
text={part.text}
|
||||
part={part as Agents.MessageContentText | Agents.ReasoningDeltaUpdate}
|
||||
messageId={messageId}
|
||||
isSubmitting={isSubmitting}
|
||||
enterEdit={enterEdit}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { TEditProps } from '~/common';
|
||||
import Container from '~/components/Chat/Messages/Content/Container';
|
||||
import { useChatContext, useAddedChatContext } from '~/Providers';
|
||||
@@ -12,18 +13,19 @@ import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
const EditTextPart = ({
|
||||
text,
|
||||
part,
|
||||
index,
|
||||
messageId,
|
||||
isSubmitting,
|
||||
enterEdit,
|
||||
}: Omit<TEditProps, 'message' | 'ask'> & {
|
||||
}: Omit<TEditProps, 'message' | 'ask' | 'text'> & {
|
||||
index: number;
|
||||
messageId: string;
|
||||
part: Agents.MessageContentText | Agents.ReasoningDeltaUpdate;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { addedIndex } = useAddedChatContext();
|
||||
const { getMessages, setMessages, conversation } = useChatContext();
|
||||
const { ask, getMessages, setMessages, conversation } = useChatContext();
|
||||
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
|
||||
store.latestMessageFamily(addedIndex),
|
||||
);
|
||||
@@ -34,15 +36,16 @@ const EditTextPart = ({
|
||||
[getMessages, messageId],
|
||||
);
|
||||
|
||||
const chatDirection = useRecoilValue(store.chatDirection);
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const updateMessageContentMutation = useUpdateMessageContentMutation(conversationId ?? '');
|
||||
|
||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
const isRTL = chatDirection === 'rtl';
|
||||
const isRTL = chatDirection?.toLowerCase() === 'rtl';
|
||||
|
||||
const { register, handleSubmit, setValue } = useForm({
|
||||
defaultValues: {
|
||||
text: text ?? '',
|
||||
text: (ContentTypes.THINK in part ? part.think : part.text) || '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,15 +58,7 @@ const EditTextPart = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
/*
|
||||
const resubmitMessage = () => {
|
||||
showToast({
|
||||
status: 'warning',
|
||||
message: localize('com_warning_resubmit_unsupported'),
|
||||
});
|
||||
|
||||
// const resubmitMessage = (data: { text: string }) => {
|
||||
// Not supported by AWS Bedrock
|
||||
const resubmitMessage = (data: { text: string }) => {
|
||||
const messages = getMessages();
|
||||
const parentMessage = messages?.find((msg) => msg.messageId === message?.parentMessageId);
|
||||
|
||||
@@ -73,17 +68,19 @@ const EditTextPart = ({
|
||||
ask(
|
||||
{ ...parentMessage },
|
||||
{
|
||||
editedText: data.text,
|
||||
editedContent: {
|
||||
index,
|
||||
text: data.text,
|
||||
type: part.type,
|
||||
},
|
||||
editedMessageId: messageId,
|
||||
isRegenerate: true,
|
||||
isEdited: true,
|
||||
},
|
||||
);
|
||||
|
||||
setSiblingIdx((siblingIdx ?? 0) - 1);
|
||||
enterEdit(true);
|
||||
};
|
||||
*/
|
||||
|
||||
const updateMessage = (data: { text: string }) => {
|
||||
const messages = getMessages();
|
||||
@@ -167,13 +164,13 @@ const EditTextPart = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex w-full justify-center text-center">
|
||||
{/* <button
|
||||
<button
|
||||
className="btn btn-primary relative mr-2"
|
||||
disabled={isSubmitting}
|
||||
onClick={handleSubmit(resubmitMessage)}
|
||||
>
|
||||
{localize('com_ui_save_submit')}
|
||||
</button> */}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary relative mr-2"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -233,9 +233,17 @@ export default function Fork({
|
||||
status: 'info',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error) => {
|
||||
/** Rate limit error (429 status code) */
|
||||
const isRateLimitError =
|
||||
(error as any)?.response?.status === 429 ||
|
||||
(error as any)?.status === 429 ||
|
||||
(error as any)?.statusCode === 429;
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_fork_error'),
|
||||
message: isRateLimitError
|
||||
? localize('com_ui_fork_error_rate_limit')
|
||||
: localize('com_ui_fork_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -62,6 +62,7 @@ const errorMessages = {
|
||||
const { info } = json;
|
||||
return info;
|
||||
},
|
||||
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
|
||||
[ViolationTypes.BAN]:
|
||||
'Your account has been temporarily banned due to violations of our service.',
|
||||
invalid_api_key:
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
General,
|
||||
Chat,
|
||||
Speech,
|
||||
Beta,
|
||||
Commands,
|
||||
Data,
|
||||
Account,
|
||||
@@ -233,9 +232,6 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
<Tabs.Content value={SettingsTabValues.CHAT}>
|
||||
<Chat />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.BETA}>
|
||||
<Beta />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.COMMANDS}>
|
||||
<Commands />
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import CodeArtifacts from './CodeArtifacts';
|
||||
import ChatBadges from './ChatBadges';
|
||||
|
||||
function Beta() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<CodeArtifacts />
|
||||
</div>
|
||||
{/* <div className="pb-3">
|
||||
<ChatBadges />
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Beta);
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ChatBadges() {
|
||||
const setIsEditing = useSetRecoilState<boolean>(store.isEditingBadges);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleEditChatBadges = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_edit_chat_badges')}</div>
|
||||
<Button variant="outline" onClick={handleEditChatBadges}>
|
||||
{localize('com_ui_edit')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function CodeArtifacts() {
|
||||
const [codeArtifacts, setCodeArtifacts] = useRecoilState<boolean>(store.codeArtifacts);
|
||||
const [includeShadcnui, setIncludeShadcnui] = useRecoilState<boolean>(store.includeShadcnui);
|
||||
const [customPromptMode, setCustomPromptMode] = useRecoilState<boolean>(store.customPromptMode);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCodeArtifactsChange = (value: boolean) => {
|
||||
setCodeArtifacts(value);
|
||||
if (!value) {
|
||||
setIncludeShadcnui(false);
|
||||
setCustomPromptMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncludeShadcnuiChange = (value: boolean) => {
|
||||
setIncludeShadcnui(value);
|
||||
};
|
||||
|
||||
const handleCustomPromptModeChange = (value: boolean) => {
|
||||
setCustomPromptMode(value);
|
||||
if (value) {
|
||||
setIncludeShadcnui(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">{localize('com_ui_artifacts')}</h3>
|
||||
<div className="space-y-2">
|
||||
<SwitchItem
|
||||
id="codeArtifacts"
|
||||
label={localize('com_ui_artifacts_toggle')}
|
||||
checked={codeArtifacts}
|
||||
onCheckedChange={handleCodeArtifactsChange}
|
||||
hoverCardText="com_nav_info_code_artifacts"
|
||||
/>
|
||||
<SwitchItem
|
||||
id="includeShadcnui"
|
||||
label={localize('com_ui_include_shadcnui')}
|
||||
checked={includeShadcnui}
|
||||
onCheckedChange={handleIncludeShadcnuiChange}
|
||||
hoverCardText="com_nav_info_include_shadcnui"
|
||||
disabled={!codeArtifacts || customPromptMode}
|
||||
/>
|
||||
<SwitchItem
|
||||
id="customPromptMode"
|
||||
label={localize('com_ui_custom_prompt_mode')}
|
||||
checked={customPromptMode}
|
||||
onCheckedChange={handleCustomPromptModeChange}
|
||||
hoverCardText="com_nav_info_custom_prompt_mode"
|
||||
disabled={!codeArtifacts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchItem({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
hoverCardText,
|
||||
disabled = false,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
hoverCardText: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={disabled ? 'text-gray-400' : ''}>{label}</div>
|
||||
<HoverCardSettings side="bottom" text={hoverCardText} />
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid={id}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export const LangSelector = ({
|
||||
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
|
||||
{ value: 'he-HE', label: localize('com_nav_lang_hebrew') },
|
||||
{ value: 'hu-HU', label: localize('com_nav_lang_hungarian') },
|
||||
{ value: 'hy-AM', label: localize('com_nav_lang_armenian') },
|
||||
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
|
||||
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
|
||||
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
|
||||
@@ -96,9 +97,11 @@ export const LangSelector = ({
|
||||
{ value: 'cs-CZ', label: localize('com_nav_lang_czech') },
|
||||
{ value: 'sv-SE', label: localize('com_nav_lang_swedish') },
|
||||
{ value: 'ko-KR', label: localize('com_nav_lang_korean') },
|
||||
{ value: 'lv-LV', label: localize('com_nav_lang_latvian') },
|
||||
{ value: 'vi-VN', label: localize('com_nav_lang_vietnamese') },
|
||||
{ value: 'th-TH', label: localize('com_nav_lang_thai') },
|
||||
{ value: 'tr-TR', label: localize('com_nav_lang_turkish') },
|
||||
{ value: 'ug', label: localize('com_nav_lang_uyghur') },
|
||||
{ value: 'nl-NL', label: localize('com_nav_lang_dutch') },
|
||||
{ value: 'id-ID', label: localize('com_nav_lang_indonesia') },
|
||||
{ value: 'fi-FI', label: localize('com_nav_lang_finnish') },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { default as General } from './General/General';
|
||||
export { default as Chat } from './Chat/Chat';
|
||||
export { default as Data } from './Data/Data';
|
||||
export { default as Beta } from './Beta/Beta';
|
||||
export { default as Commands } from './Commands/Commands';
|
||||
export { RevokeKeysButton } from './Data/RevokeKeysButton';
|
||||
export { default as Account } from './Account/Account';
|
||||
|
||||
@@ -2,20 +2,20 @@ import { useMemo } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { AgentCapabilities } from 'librechat-data-provider';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import MaxAgentSteps from './MaxAgentSteps';
|
||||
import AgentChain from './AgentChain';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import AgentChain from './AgentChain';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AdvancedPanel({
|
||||
agentsConfig,
|
||||
setActivePanel,
|
||||
}: Pick<AgentPanelProps, 'setActivePanel' | 'agentsConfig'>) {
|
||||
export default function AdvancedPanel() {
|
||||
const localize = useLocalize();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const { control, watch } = methods;
|
||||
const currentAgentId = watch('id');
|
||||
|
||||
const { agentsConfig, setActivePanel } = useAgentPanelContext();
|
||||
const chainEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities.includes(AgentCapabilities.chain) ?? false,
|
||||
[agentsConfig],
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
||||
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
|
||||
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
|
||||
import Action from '~/components/SidePanel/Builder/Action';
|
||||
import { ToolSelectDialog } from '~/components/Tools';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
@@ -26,17 +27,20 @@ const inputClass = cn(
|
||||
removeFocusOutlines,
|
||||
);
|
||||
|
||||
export default function AgentConfig({
|
||||
agentsConfig,
|
||||
createMutation,
|
||||
endpointsConfig,
|
||||
}: Pick<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
|
||||
export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'createMutation'>) {
|
||||
const localize = useLocalize();
|
||||
const fileMap = useFileMapContext();
|
||||
const { showToast } = useToastContext();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const [showToolDialog, setShowToolDialog] = useState(false);
|
||||
const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext();
|
||||
const {
|
||||
actions,
|
||||
setAction,
|
||||
agentsConfig,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
groupedTools: allTools,
|
||||
} = useAgentPanelContext();
|
||||
|
||||
const { control } = methods;
|
||||
const provider = useWatch({ control, name: 'provider' });
|
||||
@@ -45,34 +49,15 @@ export default function AgentConfig({
|
||||
const tools = useWatch({ control, name: 'tools' });
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
|
||||
const toolsEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.tools) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const actionsEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.actions) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const artifactsEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.artifacts) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const ocrEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.ocr) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const fileSearchEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.file_search) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const webSearchEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.web_search) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const codeEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.execute_code) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const {
|
||||
ocrEnabled,
|
||||
codeEnabled,
|
||||
toolsEnabled,
|
||||
actionsEnabled,
|
||||
artifactsEnabled,
|
||||
webSearchEnabled,
|
||||
fileSearchEnabled,
|
||||
} = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
|
||||
const context_files = useMemo(() => {
|
||||
if (typeof agent === 'string') {
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
Constants,
|
||||
SystemRoles,
|
||||
EModelEndpoint,
|
||||
TAgentsEndpoint,
|
||||
TEndpointsConfig,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AgentForm, StringOption } from '~/common';
|
||||
@@ -30,19 +28,15 @@ import { Button } from '~/components';
|
||||
import ModelPanel from './ModelPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentPanel({
|
||||
agentsConfig,
|
||||
endpointsConfig,
|
||||
}: {
|
||||
agentsConfig: TAgentsEndpoint | null;
|
||||
endpointsConfig: TEndpointsConfig;
|
||||
}) {
|
||||
export default function AgentPanel() {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const {
|
||||
activePanel,
|
||||
agentsConfig,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
setCurrentAgentId,
|
||||
agent_id: current_agent_id,
|
||||
} = useAgentPanelContext();
|
||||
@@ -323,14 +317,10 @@ export default function AgentPanel({
|
||||
<ModelPanel models={models} providers={providers} setActivePanel={setActivePanel} />
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
|
||||
<AgentConfig
|
||||
createMutation={create}
|
||||
agentsConfig={agentsConfig}
|
||||
endpointsConfig={endpointsConfig}
|
||||
/>
|
||||
<AgentConfig createMutation={create} />
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && (
|
||||
<AdvancedPanel setActivePanel={setActivePanel} agentsConfig={agentsConfig} />
|
||||
<AdvancedPanel />
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && (
|
||||
<AgentFooter
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { useEffect } from 'react';
|
||||
import { AgentPanelProvider, useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import VersionPanel from './Version/VersionPanel';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import ActionsPanel from './ActionsPanel';
|
||||
import AgentPanel from './AgentPanel';
|
||||
import MCPPanel from './MCPPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentPanelSwitch() {
|
||||
@@ -21,21 +19,6 @@ function AgentPanelSwitchWithContext() {
|
||||
const { conversation } = useChatContext();
|
||||
const { activePanel, setCurrentAgentId } = useAgentPanelContext();
|
||||
|
||||
// TODO: Implement MCP endpoint
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
|
||||
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
|
||||
if (!config) return null;
|
||||
|
||||
return {
|
||||
...(config as TConfig),
|
||||
capabilities: Array.isArray(config.capabilities)
|
||||
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
|
||||
: ([] as AgentCapabilities[]),
|
||||
} as TAgentsEndpoint;
|
||||
}, [endpointsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const agent_id = conversation?.agent_id ?? '';
|
||||
if (agent_id) {
|
||||
@@ -53,5 +36,8 @@ function AgentPanelSwitchWithContext() {
|
||||
if (activePanel === Panel.version) {
|
||||
return <VersionPanel />;
|
||||
}
|
||||
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
|
||||
if (activePanel === Panel.mcp) {
|
||||
return <MCPPanel />;
|
||||
}
|
||||
return <AgentPanel />;
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function AgentTool({
|
||||
}}
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
|
||||
isExpanded ? 'opacity-100' : 'opacity-0',
|
||||
isExpanded ? 'visible' : 'pointer-events-none invisible',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function Artifacts() {
|
||||
/>
|
||||
<SwitchItem
|
||||
id="includeShadcnui"
|
||||
label={localize('com_ui_include_shadcnui_agent')}
|
||||
label={localize('com_ui_include_shadcnui')}
|
||||
checked={isShadcnEnabled}
|
||||
onCheckedChange={handleShadcnuiChange}
|
||||
hoverCardText={localize('com_nav_info_include_shadcnui')}
|
||||
|
||||
@@ -1,31 +1,58 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { MCP, MCPMetadata } from 'librechat-data-provider';
|
||||
import { MCPConfig } from '~/components/SidePanel/MCP/MCPConfig';
|
||||
import type { MCP } from 'librechat-data-provider';
|
||||
import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
|
||||
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
|
||||
import { Label, Checkbox } from '~/components/ui';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { MCPForm } from '~/common/types';
|
||||
|
||||
function useUpdateAgentMCP({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess: (data: [string, MCP]) => void;
|
||||
onError: (error: Error) => void;
|
||||
}) {
|
||||
return {
|
||||
mutate: async ({
|
||||
mcp_id,
|
||||
metadata,
|
||||
agent_id,
|
||||
}: {
|
||||
mcp_id?: string;
|
||||
metadata: MCP['metadata'];
|
||||
agent_id: string;
|
||||
}) => {
|
||||
try {
|
||||
// TODO: Implement MCP endpoint
|
||||
onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]);
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface MCPInputProps {
|
||||
mcp?: MCP;
|
||||
agent_id?: string;
|
||||
onSave: (mcp: MCP) => void;
|
||||
isLoading?: boolean;
|
||||
setMCP: React.Dispatch<React.SetStateAction<MCP | undefined>>;
|
||||
}
|
||||
|
||||
export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false }: MCPInputProps) {
|
||||
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
} = useFormContext<MCPForm>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<string[]>([]);
|
||||
|
||||
@@ -37,20 +64,50 @@ export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false
|
||||
}
|
||||
}, [mcp]);
|
||||
|
||||
const saveMCP = handleSubmit(async (data: MCPForm) => {
|
||||
// Generate MCP ID using server name and delimiter for new MCPs
|
||||
const mcpId =
|
||||
mcp?.mcp_id || `${data.name.replace(/\s+/g, '_').toLowerCase()}${Constants.mcp_delimiter}`;
|
||||
const updateAgentMCP = useUpdateAgentMCP({
|
||||
onSuccess(data) {
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
setMCP(data[1]);
|
||||
setShowTools(true);
|
||||
setSelectedTools(data[1].metadata.tools ?? []);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError(error) {
|
||||
showToast({
|
||||
message: (error as Error).message || localize('com_ui_update_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updatedMCP: MCP = {
|
||||
mcp_id: mcpId,
|
||||
agent_id: agent_id ?? '',
|
||||
metadata: {
|
||||
...data,
|
||||
tools: selectedTools,
|
||||
} as MCPMetadata, // Type assertion since form validation ensures required fields
|
||||
};
|
||||
onSave(updatedMCP);
|
||||
const saveMCP = handleSubmit(async (data: MCPForm) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await updateAgentMCP.mutate({
|
||||
agent_id: agent_id ?? '',
|
||||
mcp_id: mcp?.mcp_id,
|
||||
metadata: {
|
||||
...data,
|
||||
tools: selectedTools,
|
||||
},
|
||||
});
|
||||
setMCP(response[1]);
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
} catch {
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelectAll = () => {
|
||||
@@ -83,15 +140,14 @@ export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
const updatedMCP: MCP = {
|
||||
setMCP({
|
||||
mcp_id: mcp?.mcp_id ?? '',
|
||||
agent_id: agent_id ?? '',
|
||||
metadata: {
|
||||
...mcp?.metadata,
|
||||
icon: base64String,
|
||||
},
|
||||
};
|
||||
onSave(updatedMCP);
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
@@ -149,48 +205,25 @@ export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MCPConfig />
|
||||
<div className="my-2 flex items-center">
|
||||
<MCPAuth />
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<Controller
|
||||
name="trust"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={(field.value ?? false).toString()}
|
||||
/>
|
||||
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() =>
|
||||
setValue('trust', !getValues('trust'), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<label
|
||||
className="form-check-label text-token-text-primary w-full cursor-pointer"
|
||||
htmlFor="trust"
|
||||
>
|
||||
{localize('com_ui_trust_app')}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
<div className="-mt-5 ml-6">
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_agents_mcp_trust_subtext')}
|
||||
</span>
|
||||
<Label htmlFor="trust" className="flex flex-col">
|
||||
{localize('com_ui_trust_app')}
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_agents_mcp_trust_subtext')}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.trust && (
|
||||
<div className="ml-6">
|
||||
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
|
||||
</div>
|
||||
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -198,7 +231,7 @@ export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false
|
||||
<button
|
||||
onClick={saveMCP}
|
||||
disabled={isLoading}
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white transition-colors duration-200 hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:bg-green-400"
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
|
||||
type="button"
|
||||
>
|
||||
{(() => {
|
||||
@@ -1,103 +1,66 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import type { MCPForm } from '~/common';
|
||||
import {
|
||||
useCreateMCPMutation,
|
||||
useUpdateMCPMutation,
|
||||
useDeleteMCPMutation,
|
||||
} from '~/data-provider/MCPs/mutations';
|
||||
import type { MCP } from 'librechat-data-provider';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { defaultMCPFormValues } from '~/common/mcp';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import type { MCPForm } from '~/common';
|
||||
import MCPInput from './MCPInput';
|
||||
import { Panel } from '~/common';
|
||||
import {
|
||||
AuthTypeEnum,
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
// TODO: Add MCP delete (for now mocked for ui)
|
||||
// import { useDeleteAgentMCP } from '~/data-provider';
|
||||
|
||||
interface MCPFormPanelProps {
|
||||
// Data
|
||||
mcp?: MCP;
|
||||
|
||||
// Actions
|
||||
onBack: () => void;
|
||||
|
||||
// UI customization
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showDeleteButton?: boolean;
|
||||
deleteConfirmMessage?: string;
|
||||
|
||||
// Form customization
|
||||
defaultValues?: Partial<MCPForm>;
|
||||
function useDeleteAgentMCP({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}) {
|
||||
return {
|
||||
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => {
|
||||
try {
|
||||
console.log('Mock delete MCP:', { mcp_id, agent_id });
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function MCPFormPanel({
|
||||
mcp,
|
||||
onBack,
|
||||
title,
|
||||
subtitle,
|
||||
showDeleteButton = true,
|
||||
deleteConfirmMessage,
|
||||
defaultValues = defaultMCPFormValues,
|
||||
}: MCPFormPanelProps) {
|
||||
export default function MCPPanel() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const create = useCreateMCPMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
onBack();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error creating MCP:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const update = useUpdateMCPMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
onBack();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error updating MCP:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMCP = useDeleteMCPMutation({
|
||||
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
|
||||
const deleteAgentMCP = useDeleteAgentMCP({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_delete_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
onBack();
|
||||
setActivePanel(Panel.builder);
|
||||
setMcp(undefined);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error deleting MCP:', error);
|
||||
onError(error) {
|
||||
showToast({
|
||||
message: localize('com_ui_delete_mcp_error'),
|
||||
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const methods = useForm<MCPForm>({
|
||||
defaultValues: defaultValues,
|
||||
defaultValues: defaultMCPFormValues,
|
||||
});
|
||||
|
||||
const { reset } = methods;
|
||||
@@ -111,51 +74,55 @@ export default function MCPFormPanel({
|
||||
url: mcp.metadata.url ?? '',
|
||||
tools: mcp.metadata.tools ?? [],
|
||||
trust: mcp.metadata.trust ?? false,
|
||||
customHeaders: mcp.metadata.customHeaders ?? [],
|
||||
requestTimeout: mcp.metadata.requestTimeout,
|
||||
connectionTimeout: mcp.metadata.connectionTimeout,
|
||||
};
|
||||
|
||||
if (mcp.metadata.auth) {
|
||||
Object.assign(formData, {
|
||||
type: mcp.metadata.auth.type || AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
api_key: mcp.metadata.api_key ?? '',
|
||||
authorization_type: mcp.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic,
|
||||
oauth_client_id: mcp.metadata.oauth_client_id ?? '',
|
||||
oauth_client_secret: mcp.metadata.oauth_client_secret ?? '',
|
||||
authorization_url: mcp.metadata.auth.authorization_url ?? '',
|
||||
client_url: mcp.metadata.auth.client_url ?? '',
|
||||
scope: mcp.metadata.auth.scope ?? '',
|
||||
token_exchange_method:
|
||||
mcp.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost,
|
||||
});
|
||||
}
|
||||
|
||||
reset(formData);
|
||||
}
|
||||
}, [mcp, reset]);
|
||||
|
||||
const handleSave = (mcpData: MCP) => {
|
||||
if (mcp) {
|
||||
// Update existing MCP
|
||||
update.mutate({ mcp_id: mcp.mcp_id, data: mcpData });
|
||||
} else {
|
||||
// Create new MCP
|
||||
create.mutate(mcpData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (mcp?.mcp_id) {
|
||||
deleteMCP.mutate({ mcp_id: mcp.mcp_id });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className="h-full grow overflow-hidden">
|
||||
<div className="h-full overflow-auto px-2 pb-12 text-sm">
|
||||
<div className="relative flex flex-col items-center px-16 py-6 text-center">
|
||||
<div className="absolute left-0 top-6">
|
||||
<button type="button" className="btn btn-neutral relative" onClick={onBack}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
onClick={() => {
|
||||
setActivePanel(Panel.builder);
|
||||
setMcp(undefined);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<ChevronLeft />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!!mcp && showDeleteButton && (
|
||||
{!!mcp && (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<div className="absolute right-0 top-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!mcp.mcp_id}
|
||||
disabled={!agent_id || !mcp.mcp_id}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
|
||||
>
|
||||
<TrashIcon className="text-red-500" />
|
||||
@@ -168,11 +135,22 @@ export default function MCPFormPanel({
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{deleteConfirmMessage || localize('com_ui_delete_mcp_confirm')}
|
||||
{localize('com_ui_delete_mcp_confirm')}
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleDelete,
|
||||
selectHandler: () => {
|
||||
if (!agent_id) {
|
||||
return showToast({
|
||||
message: localize('com_agents_no_agent_id_error'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
deleteAgentMCP.mutate({
|
||||
mcp_id: mcp.mcp_id,
|
||||
agent_id,
|
||||
});
|
||||
},
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
@@ -182,17 +160,11 @@ export default function MCPFormPanel({
|
||||
)}
|
||||
|
||||
<div className="text-xl font-medium">
|
||||
{title ||
|
||||
(mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server'))}
|
||||
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">{subtitle || ''}</div>
|
||||
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
|
||||
</div>
|
||||
<MCPInput
|
||||
mcp={mcp}
|
||||
agent_id=""
|
||||
onSave={handleSave}
|
||||
isLoading={create.isLoading || update.isLoading}
|
||||
/>
|
||||
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} />
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import { MCPItem } from '~/components/SidePanel/MCP/MCPItem';
|
||||
import MCP from '~/components/SidePanel/Builder/MCP';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function MCPSection() {
|
||||
@@ -30,7 +30,7 @@ export default function MCPSection() {
|
||||
{mcps
|
||||
.filter((mcp) => mcp.agent_id === agent_id)
|
||||
.map((mcp, i) => (
|
||||
<MCPItem
|
||||
<MCP
|
||||
key={i}
|
||||
mcp={mcp}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { AuthType, SearchCategories, RerankerTypes } from 'librechat-data-provider';
|
||||
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
|
||||
import {
|
||||
AuthType,
|
||||
SearchCategories,
|
||||
RerankerTypes,
|
||||
SearchProviders,
|
||||
ScraperTypes,
|
||||
} from 'librechat-data-provider';
|
||||
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
|
||||
import type { MenuItemProps } from '~/common';
|
||||
import { Input, Button, OGDialog, Label } from '~/components/ui';
|
||||
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
|
||||
import InputSection, { type DropdownOption } from './InputSection';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import DropdownPopup from '~/components/ui/DropdownPopup';
|
||||
import { Button, OGDialog } from '~/components/ui';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
@@ -36,151 +39,119 @@ export default function ApiKeyDialog({
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const [selectedReranker, setSelectedReranker] = useState<
|
||||
RerankerTypes.JINA | RerankerTypes.COHERE
|
||||
>(
|
||||
config?.webSearch?.rerankerType === RerankerTypes.COHERE
|
||||
? RerankerTypes.COHERE
|
||||
: RerankerTypes.JINA,
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState(
|
||||
config?.webSearch?.searchProvider || SearchProviders.SERPER,
|
||||
);
|
||||
const [selectedReranker, setSelectedReranker] = useState(
|
||||
config?.webSearch?.rerankerType || RerankerTypes.JINA,
|
||||
);
|
||||
const [selectedScraper, setSelectedScraper] = useState(ScraperTypes.FIRECRAWL);
|
||||
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false);
|
||||
const [scraperDropdownOpen, setScraperDropdownOpen] = useState(false);
|
||||
const [rerankerDropdownOpen, setRerankerDropdownOpen] = useState(false);
|
||||
|
||||
const providerItems: MenuItemProps[] = [
|
||||
const providerOptions: DropdownOption[] = [
|
||||
{
|
||||
key: SearchProviders.SERPER,
|
||||
label: localize('com_ui_web_search_provider_serper'),
|
||||
onClick: () => {},
|
||||
inputs: {
|
||||
serperApiKey: {
|
||||
placeholder: localize('com_ui_enter_api_key'),
|
||||
type: 'password' as const,
|
||||
link: {
|
||||
url: 'https://serper.dev/api-keys',
|
||||
text: localize('com_ui_web_search_provider_serper_key'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: SearchProviders.SEARXNG,
|
||||
label: localize('com_ui_web_search_provider_searxng'),
|
||||
inputs: {
|
||||
searxngInstanceUrl: {
|
||||
placeholder: localize('com_ui_web_search_searxng_instance_url'),
|
||||
type: 'text' as const,
|
||||
},
|
||||
searxngApiKey: {
|
||||
placeholder: localize('com_ui_web_search_searxng_api_key'),
|
||||
type: 'password' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const scraperItems: MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_ui_web_search_scraper_firecrawl'),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
const rerankerItems: MenuItemProps[] = [
|
||||
const rerankerOptions: DropdownOption[] = [
|
||||
{
|
||||
key: RerankerTypes.JINA,
|
||||
label: localize('com_ui_web_search_reranker_jina'),
|
||||
onClick: () => setSelectedReranker(RerankerTypes.JINA),
|
||||
inputs: {
|
||||
jinaApiKey: {
|
||||
placeholder: localize('com_ui_web_search_jina_key'),
|
||||
type: 'password' as const,
|
||||
link: {
|
||||
url: 'https://jina.ai/api-dashboard/',
|
||||
text: localize('com_ui_web_search_reranker_jina_key'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: RerankerTypes.COHERE,
|
||||
label: localize('com_ui_web_search_reranker_cohere'),
|
||||
onClick: () => setSelectedReranker(RerankerTypes.COHERE),
|
||||
inputs: {
|
||||
cohereApiKey: {
|
||||
placeholder: localize('com_ui_web_search_cohere_key'),
|
||||
type: 'password' as const,
|
||||
link: {
|
||||
url: 'https://dashboard.cohere.com/welcome/login',
|
||||
text: localize('com_ui_web_search_reranker_cohere_key'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const showProviderDropdown = !config?.webSearch?.searchProvider;
|
||||
const showScraperDropdown = !config?.webSearch?.scraperType;
|
||||
const showRerankerDropdown = !config?.webSearch?.rerankerType;
|
||||
const scraperOptions: DropdownOption[] = [
|
||||
{
|
||||
key: ScraperTypes.FIRECRAWL,
|
||||
label: localize('com_ui_web_search_scraper_firecrawl'),
|
||||
inputs: {
|
||||
firecrawlApiUrl: {
|
||||
placeholder: localize('com_ui_web_search_firecrawl_url'),
|
||||
type: 'text' as const,
|
||||
},
|
||||
firecrawlApiKey: {
|
||||
placeholder: localize('com_ui_enter_api_key'),
|
||||
type: 'password' as const,
|
||||
link: {
|
||||
url: 'https://docs.firecrawl.dev/introduction#api-key',
|
||||
text: localize('com_ui_web_search_scraper_firecrawl_key'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = useState({
|
||||
provider: false,
|
||||
reranker: false,
|
||||
scraper: false,
|
||||
});
|
||||
|
||||
// Determine which categories are SYSTEM_DEFINED
|
||||
const providerAuthType = authTypes.find(([cat]) => cat === SearchCategories.PROVIDERS)?.[1];
|
||||
const scraperAuthType = authTypes.find(([cat]) => cat === SearchCategories.SCRAPERS)?.[1];
|
||||
const rerankerAuthType = authTypes.find(([cat]) => cat === SearchCategories.RERANKERS)?.[1];
|
||||
|
||||
function renderRerankerInput() {
|
||||
if (config?.webSearch?.rerankerType === RerankerTypes.JINA) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_jina_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('jinaApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://jina.ai/api-dashboard/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_jina_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (config?.webSearch?.rerankerType === RerankerTypes.COHERE) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_cohere_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('cohereApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://dashboard.cohere.com/welcome/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_cohere_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.JINA) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_jina_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('jinaApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://jina.ai/api-dashboard/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_jina_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.COHERE) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_cohere_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('cohereApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://dashboard.cohere.com/welcome/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_cohere_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const handleProviderChange = (key: string) => {
|
||||
setSelectedProvider(key as SearchProviders);
|
||||
};
|
||||
|
||||
const handleRerankerChange = (key: string) => {
|
||||
setSelectedReranker(key as RerankerTypes);
|
||||
};
|
||||
|
||||
const handleScraperChange = (key: string) => {
|
||||
setSelectedScraper(key as ScraperTypes);
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog
|
||||
@@ -195,153 +166,56 @@ export default function ApiKeyDialog({
|
||||
main={
|
||||
<>
|
||||
<div className="mb-4 text-center font-medium">{localize('com_ui_web_search')}</div>
|
||||
<div className="mb-4 text-center text-sm">
|
||||
{localize('com_ui_web_search_api_subtitle')}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Search Provider Section */}
|
||||
{/* Provider Section */}
|
||||
{providerAuthType !== AuthType.SYSTEM_DEFINED && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-md w-fit font-medium">
|
||||
{localize('com_ui_web_search_provider')}
|
||||
</Label>
|
||||
{showProviderDropdown ? (
|
||||
<DropdownPopup
|
||||
menuId="search-provider-dropdown"
|
||||
items={providerItems}
|
||||
isOpen={providerDropdownOpen}
|
||||
setIsOpen={setProviderDropdownOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
|
||||
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_web_search_provider_serper')}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_ui_web_search_provider_serper')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`${localize('com_ui_enter_api_key')}`}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('serperApiKey', { required: true })}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://serper.dev/api-key"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_provider_serper_key')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<InputSection
|
||||
title={localize('com_ui_web_search_provider')}
|
||||
selectedKey={selectedProvider}
|
||||
onSelectionChange={handleProviderChange}
|
||||
dropdownOptions={providerOptions}
|
||||
showDropdown={!config?.webSearch?.searchProvider}
|
||||
register={register}
|
||||
dropdownOpen={dropdownOpen.provider}
|
||||
setDropdownOpen={(open) =>
|
||||
setDropdownOpen((prev) => ({ ...prev, provider: open }))
|
||||
}
|
||||
dropdownKey="provider"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scraper Section */}
|
||||
{scraperAuthType !== AuthType.SYSTEM_DEFINED && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-md w-fit font-medium">
|
||||
{localize('com_ui_web_search_scraper')}
|
||||
</Label>
|
||||
{showScraperDropdown ? (
|
||||
<DropdownPopup
|
||||
menuId="scraper-dropdown"
|
||||
items={scraperItems}
|
||||
isOpen={scraperDropdownOpen}
|
||||
setIsOpen={setScraperDropdownOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setScraperDropdownOpen(!scraperDropdownOpen)}
|
||||
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_web_search_scraper_firecrawl')}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_ui_web_search_scraper_firecrawl')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`${localize('com_ui_enter_api_key')}`}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
className="mb-2"
|
||||
{...register('firecrawlApiKey')}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={localize('com_ui_web_search_firecrawl_url')}
|
||||
className="mb-1"
|
||||
{...register('firecrawlApiUrl')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://docs.firecrawl.dev/introduction#api-key"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_scraper_firecrawl_key')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<InputSection
|
||||
title={localize('com_ui_web_search_scraper')}
|
||||
selectedKey={selectedScraper}
|
||||
onSelectionChange={handleScraperChange}
|
||||
dropdownOptions={scraperOptions}
|
||||
showDropdown={!config?.webSearch?.scraperType}
|
||||
register={register}
|
||||
dropdownOpen={dropdownOpen.scraper}
|
||||
setDropdownOpen={(open) =>
|
||||
setDropdownOpen((prev) => ({ ...prev, scraper: open }))
|
||||
}
|
||||
dropdownKey="scraper"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reranker Section */}
|
||||
{rerankerAuthType !== AuthType.SYSTEM_DEFINED && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-md w-fit font-medium">
|
||||
{localize('com_ui_web_search_reranker')}
|
||||
</Label>
|
||||
{showRerankerDropdown && (
|
||||
<DropdownPopup
|
||||
menuId="reranker-dropdown"
|
||||
isOpen={rerankerDropdownOpen}
|
||||
setIsOpen={setRerankerDropdownOpen}
|
||||
items={rerankerItems}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setRerankerDropdownOpen(!rerankerDropdownOpen)}
|
||||
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
|
||||
>
|
||||
{selectedReranker === RerankerTypes.JINA
|
||||
? localize('com_ui_web_search_reranker_jina')
|
||||
: localize('com_ui_web_search_reranker_cohere')}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!showRerankerDropdown && (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{config?.webSearch?.rerankerType === RerankerTypes.COHERE
|
||||
? localize('com_ui_web_search_reranker_cohere')
|
||||
: localize('com_ui_web_search_reranker_jina')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderRerankerInput()}
|
||||
</div>
|
||||
<InputSection
|
||||
title={localize('com_ui_web_search_reranker')}
|
||||
selectedKey={selectedReranker}
|
||||
onSelectionChange={handleRerankerChange}
|
||||
dropdownOptions={rerankerOptions}
|
||||
showDropdown={!config?.webSearch?.rerankerType}
|
||||
register={register}
|
||||
dropdownOpen={dropdownOpen.reranker}
|
||||
setDropdownOpen={(open) =>
|
||||
setDropdownOpen((prev) => ({ ...prev, reranker: open }))
|
||||
}
|
||||
dropdownKey="reranker"
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
@@ -353,10 +227,7 @@ export default function ApiKeyDialog({
|
||||
}}
|
||||
buttons={
|
||||
isToolAuthenticated && (
|
||||
<Button
|
||||
onClick={onRevoke}
|
||||
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
|
||||
>
|
||||
<Button onClick={onRevoke} className="bg-red-500 text-white hover:bg-red-600">
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
144
client/src/components/SidePanel/Agents/Search/InputSection.tsx
Normal file
144
client/src/components/SidePanel/Agents/Search/InputSection.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
|
||||
import type { MenuItemProps } from '~/common';
|
||||
import { Input, Label } from '~/components/ui';
|
||||
import DropdownPopup from '~/components/ui/DropdownPopup';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface InputConfig {
|
||||
placeholder: string;
|
||||
type?: 'text' | 'password';
|
||||
link?: {
|
||||
url: string;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DropdownOption {
|
||||
key: string;
|
||||
label: string;
|
||||
inputs?: Record<string, InputConfig>;
|
||||
}
|
||||
|
||||
interface InputSectionProps {
|
||||
title: string;
|
||||
selectedKey: string;
|
||||
onSelectionChange: (key: string) => void;
|
||||
dropdownOptions: DropdownOption[];
|
||||
showDropdown: boolean;
|
||||
register: UseFormRegister<SearchApiKeyFormData>;
|
||||
dropdownOpen: boolean;
|
||||
setDropdownOpen: (open: boolean) => void;
|
||||
dropdownKey: string;
|
||||
}
|
||||
|
||||
export default function InputSection({
|
||||
title,
|
||||
selectedKey,
|
||||
onSelectionChange,
|
||||
dropdownOptions,
|
||||
showDropdown,
|
||||
register,
|
||||
dropdownOpen,
|
||||
setDropdownOpen,
|
||||
dropdownKey,
|
||||
}: InputSectionProps) {
|
||||
const localize = useLocalize();
|
||||
const [passwordVisibility, setPasswordVisibility] = useState<Record<string, boolean>>({});
|
||||
const selectedOption = dropdownOptions.find((opt) => opt.key === selectedKey);
|
||||
const dropdownItems: MenuItemProps[] = dropdownOptions.map((option) => ({
|
||||
label: option.label,
|
||||
onClick: () => onSelectionChange(option.key),
|
||||
}));
|
||||
|
||||
const togglePasswordVisibility = (fieldName: string) => {
|
||||
setPasswordVisibility((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: !prev[fieldName],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-md w-fit font-medium">{title}</Label>
|
||||
{showDropdown ? (
|
||||
<DropdownPopup
|
||||
menuId={`${dropdownKey}-dropdown`}
|
||||
items={dropdownItems}
|
||||
isOpen={dropdownOpen}
|
||||
setIsOpen={setDropdownOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
|
||||
>
|
||||
{selectedOption?.label}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary">{selectedOption?.label}</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedOption?.inputs &&
|
||||
Object.entries(selectedOption.inputs).map(([name, config], index) => (
|
||||
<div key={name}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={'text'} // so password autofill doesn't show
|
||||
placeholder={config.placeholder}
|
||||
autoComplete={config.type === 'password' ? 'one-time-code' : 'off'}
|
||||
readOnly={config.type === 'password'}
|
||||
onFocus={
|
||||
config.type === 'password' ? (e) => (e.target.readOnly = false) : undefined
|
||||
}
|
||||
className={`${index > 0 ? 'mb-2' : 'mb-2'} ${
|
||||
config.type === 'password' ? 'pr-10' : ''
|
||||
}`}
|
||||
{...register(name as keyof SearchApiKeyFormData)}
|
||||
/>
|
||||
{config.type === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePasswordVisibility(name)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary transition-colors hover:text-text-primary"
|
||||
aria-label={
|
||||
passwordVisibility[name]
|
||||
? localize('com_ui_hide_password')
|
||||
: localize('com_ui_show_password')
|
||||
}
|
||||
>
|
||||
<div className="relative h-4 w-4">
|
||||
{passwordVisibility[name] ? (
|
||||
<EyeOff className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in" />
|
||||
) : (
|
||||
<Eye className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{config.link && (
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href={config.link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{config.link.text}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { InputConfig, DropdownOption };
|
||||
@@ -9,7 +9,7 @@ type MCPProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function MCPItem({ mcp, onClick }: MCPProps) {
|
||||
export default function MCP({ mcp, onClick }: MCPProps) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
return (
|
||||
55
client/src/components/SidePanel/Builder/MCPAuth.tsx
Normal file
55
client/src/components/SidePanel/Builder/MCPAuth.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
|
||||
import {
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
AuthTypeEnum,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
export default function MCPAuth() {
|
||||
// Create a separate form for auth
|
||||
const authMethods = useForm({
|
||||
defaultValues: {
|
||||
/* General */
|
||||
type: AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
/* API key */
|
||||
api_key: '',
|
||||
authorization_type: AuthorizationTypeEnum.Basic,
|
||||
custom_auth_header: '',
|
||||
/* OAuth */
|
||||
oauth_client_id: '',
|
||||
oauth_client_secret: '',
|
||||
authorization_url: '',
|
||||
client_url: '',
|
||||
scope: '',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
},
|
||||
});
|
||||
|
||||
const { watch, setValue } = authMethods;
|
||||
const type = watch('type');
|
||||
|
||||
// Sync form state when auth type changes
|
||||
useEffect(() => {
|
||||
if (type === 'none') {
|
||||
// Reset auth fields when type is none
|
||||
setValue('api_key', '');
|
||||
setValue('authorization_type', AuthorizationTypeEnum.Basic);
|
||||
setValue('custom_auth_header', '');
|
||||
setValue('oauth_client_id', '');
|
||||
setValue('oauth_client_secret', '');
|
||||
setValue('authorization_url', '');
|
||||
setValue('client_url', '');
|
||||
setValue('scope', '');
|
||||
setValue('token_exchange_method', TokenExchangeMethodEnum.DefaultPost);
|
||||
}
|
||||
}, [type, setValue]);
|
||||
|
||||
return (
|
||||
<FormProvider {...authMethods}>
|
||||
<ActionsAuth />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Plus, Trash2, CirclePlus } from 'lucide-react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '~/components/ui/Accordion';
|
||||
import { DropdownPopup } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface UserInfoPlaceholder {
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const userInfoPlaceholders: UserInfoPlaceholder[] = [
|
||||
{ label: 'user-id', value: '{{LIBRECHAT_USER_ID}}', description: 'Current user ID' },
|
||||
{ label: 'username', value: '{{LIBRECHAT_USER_USERNAME}}', description: 'Current username' },
|
||||
{ label: 'email', value: '{{LIBRECHAT_USER_EMAIL}}', description: 'Current user email' },
|
||||
{ label: 'name', value: '{{LIBRECHAT_USER_NAME}}', description: 'Current user name' },
|
||||
{
|
||||
label: 'provider',
|
||||
value: '{{LIBRECHAT_USER_PROVIDER}}',
|
||||
description: 'Authentication provider',
|
||||
},
|
||||
{ label: 'role', value: '{{LIBRECHAT_USER_ROLE}}', description: 'User role' },
|
||||
];
|
||||
|
||||
export function MCPConfig() {
|
||||
const localize = useLocalize();
|
||||
const { register, watch, setValue } = useFormContext();
|
||||
const [isHeadersMenuOpen, setIsHeadersMenuOpen] = useState(false);
|
||||
|
||||
const customHeaders = watch('customHeaders') || [];
|
||||
|
||||
const addCustomHeader = () => {
|
||||
const newHeader = {
|
||||
id: Date.now().toString(),
|
||||
name: '',
|
||||
value: '',
|
||||
};
|
||||
setValue('customHeaders', [...customHeaders, newHeader]);
|
||||
};
|
||||
|
||||
const removeCustomHeader = (id: string) => {
|
||||
setValue(
|
||||
'customHeaders',
|
||||
customHeaders.filter((header: any) => header.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
const updateCustomHeader = (id: string, field: 'name' | 'value', value: string) => {
|
||||
setValue(
|
||||
'customHeaders',
|
||||
customHeaders.map((header: any) =>
|
||||
header.id === id ? { ...header, [field]: value } : header,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddPlaceholder = (placeholder: UserInfoPlaceholder) => {
|
||||
const newHeader = {
|
||||
id: Date.now().toString(),
|
||||
name: placeholder.label,
|
||||
value: placeholder.value,
|
||||
};
|
||||
setValue('customHeaders', [...customHeaders, newHeader]);
|
||||
setIsHeadersMenuOpen(false);
|
||||
};
|
||||
|
||||
const headerMenuItems = [
|
||||
...userInfoPlaceholders.map((placeholder) => ({
|
||||
label: `${placeholder.label} - ${placeholder.description}`,
|
||||
onClick: () => handleAddPlaceholder(placeholder),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Authentication Accordion */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authentication" className="rounded-lg border border-border-medium">
|
||||
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
{localize('com_ui_authentication')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 pt-2">
|
||||
<div className="space-y-4">
|
||||
{/* Custom Headers Section - Individual Inputs Version */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_mcp_custom_headers')}
|
||||
</label>
|
||||
<DropdownPopup
|
||||
menuId="headers-menu"
|
||||
items={headerMenuItems}
|
||||
isOpen={isHeadersMenuOpen}
|
||||
setIsOpen={setIsHeadersMenuOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setIsHeadersMenuOpen(!isHeadersMenuOpen)}
|
||||
className="flex h-7 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<CirclePlus className="mr-1 h-3 w-3 text-text-secondary" />
|
||||
{localize('com_ui_mcp_headers')}
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{customHeaders.length === 0 ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm text-text-secondary">
|
||||
{localize('com_ui_mcp_no_custom_headers')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomHeader}
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{localize('com_ui_mcp_add_header')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{customHeaders.map((header: any) => (
|
||||
<div key={header.id} className="flex min-w-0 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={localize('com_ui_mcp_header_name')}
|
||||
value={header.name}
|
||||
onChange={(e) => updateCustomHeader(header.id, 'name', e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={localize('com_ui_mcp_header_value')}
|
||||
value={header.value}
|
||||
onChange={(e) => updateCustomHeader(header.id, 'value', e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomHeader(header.id)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border-medium bg-surface-primary text-text-secondary hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Add New Header Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomHeader}
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{localize('com_ui_mcp_add_header')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Configuration Accordion */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="configuration" className="rounded-lg border border-border-medium">
|
||||
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
{localize('com_ui_mcp_configuration')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 pt-2">
|
||||
<div className="space-y-4">
|
||||
{/* Request Timeout */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_mcp_request_timeout')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="10000"
|
||||
{...register('requestTimeout')}
|
||||
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_ui_mcp_request_timeout_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection Timeout */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_mcp_connection_timeout')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="10000"
|
||||
{...register('connectionTimeout')}
|
||||
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_ui_mcp_connection_timeout_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import { Button, Input, Label } from '~/components/ui';
|
||||
@@ -9,7 +9,6 @@ import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import MCPFormPanel from './MCPFormPanel';
|
||||
|
||||
interface ServerConfigWithVars {
|
||||
serverName: string;
|
||||
@@ -25,7 +24,6 @@ export default function MCPPanel() {
|
||||
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [showMCPForm, setShowMCPForm] = useState(false);
|
||||
|
||||
const mcpServerDefinitions = useMemo(() => {
|
||||
if (!startupConfig?.mcpServers) {
|
||||
@@ -91,47 +89,14 @@ export default function MCPPanel() {
|
||||
setSelectedServerNameForEditing(null);
|
||||
};
|
||||
|
||||
const handleAddMCP = () => {
|
||||
setShowMCPForm(true);
|
||||
};
|
||||
|
||||
const handleBackFromForm = () => {
|
||||
setShowMCPForm(false);
|
||||
};
|
||||
|
||||
if (showMCPForm) {
|
||||
return (
|
||||
<MCPFormPanel
|
||||
onBack={handleBackFromForm}
|
||||
title={localize('com_ui_add_mcp_server')}
|
||||
subtitle={localize('com_agents_mcp_info_chat')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (startupConfigLoading) {
|
||||
return <MCPPanelSkeleton />;
|
||||
}
|
||||
|
||||
if (mcpServerDefinitions.length === 0) {
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{localize('com_sidepanel_mcp_no_servers_with_vars')}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMCP}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{localize('com_ui_add_mcp')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{localize('com_sidepanel_mcp_no_servers_with_vars')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -179,28 +144,15 @@ export default function MCPPanel() {
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<div className="space-y-2">
|
||||
{mcpServerDefinitions.map((server) => (
|
||||
<button
|
||||
<Button
|
||||
key={server.serverName}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start dark:hover:bg-gray-700"
|
||||
onClick={() => handleServerClickToEdit(server.serverName)}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
aria-label={`Configure MCP server ${server.serverName}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
{server.serverName}
|
||||
</div>
|
||||
</button>
|
||||
{server.serverName}
|
||||
</Button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMCP}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{localize('com_ui_add_mcp')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -229,7 +181,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
|
||||
|
||||
useEffect(() => {
|
||||
// Always initialize with empty strings based on the schema
|
||||
const initialFormValues = Object.keys(server.config.customUserVars || {}).reduce(
|
||||
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = '';
|
||||
return acc;
|
||||
@@ -278,7 +230,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{Object.keys(server.config.customUserVars || {}).length > 0 && (
|
||||
{Object.keys(server.config.customUserVars).length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRevokeClick}
|
||||
|
||||
130
client/src/components/SidePanel/Parameters/DynamicSegment.tsx
Normal file
130
client/src/components/SidePanel/Parameters/DynamicSegment.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, HoverCard, HoverCardTrigger, SegmentedControl } from '~/components/ui';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
function DynamicSegment({
|
||||
label = '',
|
||||
settingKey,
|
||||
defaultValue,
|
||||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
options,
|
||||
enumMappings,
|
||||
readonly = false,
|
||||
showLabel = true,
|
||||
showDefault = false,
|
||||
labelCode = false,
|
||||
descriptionCode = false,
|
||||
conversation,
|
||||
}: DynamicSettingProps) {
|
||||
const localize = useLocalize();
|
||||
const { preset } = useChatContext();
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
return inputValue;
|
||||
}
|
||||
|
||||
return conversation?.[settingKey] ?? defaultValue;
|
||||
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setInputValue(value);
|
||||
return;
|
||||
}
|
||||
setOption(settingKey)(value);
|
||||
};
|
||||
|
||||
useParameterEffects({
|
||||
preset,
|
||||
settingKey,
|
||||
defaultValue,
|
||||
conversation,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
preventDelayedUpdate: true,
|
||||
});
|
||||
|
||||
if (!options || options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert options to SegmentedControl format with proper localization
|
||||
const segmentOptions =
|
||||
options?.map((option) => {
|
||||
const optionValue = typeof option === 'string' ? option : String(option);
|
||||
const optionLabel = typeof option === 'string' ? option : String(option);
|
||||
|
||||
// Use enum mappings for localization if available
|
||||
const localizedLabel = enumMappings?.[optionValue]
|
||||
? localize(enumMappings[optionValue] as TranslationKeys) ||
|
||||
String(enumMappings[optionValue])
|
||||
: optionLabel;
|
||||
|
||||
return {
|
||||
label: String(localizedLabel),
|
||||
value: optionValue,
|
||||
disabled: false,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-start gap-6',
|
||||
columnSpan != null ? `col-span-${columnSpan}` : 'col-span-full',
|
||||
)}
|
||||
>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
{showLabel === true && (
|
||||
<div className="flex w-full justify-between">
|
||||
<Label
|
||||
htmlFor={`${settingKey}-dynamic-segment`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}: {defaultValue})
|
||||
</small>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<SegmentedControl
|
||||
options={segmentOptions}
|
||||
value={selectedValue}
|
||||
onValueChange={handleChange}
|
||||
disabled={readonly}
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={
|
||||
descriptionCode
|
||||
? (localize(description as TranslationKeys) ?? description)
|
||||
: description
|
||||
}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSegment;
|
||||
@@ -18,6 +18,7 @@ function DynamicSlider({
|
||||
setOption,
|
||||
optionType,
|
||||
options,
|
||||
enumMappings,
|
||||
readonly = false,
|
||||
showDefault = false,
|
||||
includeInput = true,
|
||||
@@ -60,24 +61,68 @@ function DynamicSlider({
|
||||
|
||||
const enumToNumeric = useMemo(() => {
|
||||
if (isEnum && options) {
|
||||
return options.reduce((acc, mapping, index) => {
|
||||
acc[mapping] = index;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
return options.reduce(
|
||||
(acc, mapping, index) => {
|
||||
acc[mapping] = index;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}, [isEnum, options]);
|
||||
|
||||
const valueToEnumOption = useMemo(() => {
|
||||
if (isEnum && options) {
|
||||
return options.reduce((acc, option, index) => {
|
||||
acc[index] = option;
|
||||
return acc;
|
||||
}, {} as Record<number, string>);
|
||||
return options.reduce(
|
||||
(acc, option, index) => {
|
||||
acc[index] = option;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}, [isEnum, options]);
|
||||
|
||||
const getDisplayValue = useCallback(
|
||||
(value: string | number | undefined | null): string => {
|
||||
if (isEnum && enumMappings && value != null) {
|
||||
const stringValue = String(value);
|
||||
// Check if the value exists in enumMappings
|
||||
if (stringValue in enumMappings) {
|
||||
const mappedValue = String(enumMappings[stringValue]);
|
||||
// Check if the mapped value is a localization key
|
||||
if (mappedValue.startsWith('com_')) {
|
||||
return localize(mappedValue as TranslationKeys) ?? mappedValue;
|
||||
}
|
||||
return mappedValue;
|
||||
}
|
||||
}
|
||||
// Always return a string for Input component compatibility
|
||||
if (value != null) {
|
||||
return String(value);
|
||||
}
|
||||
return String(defaultValue ?? '');
|
||||
},
|
||||
[isEnum, enumMappings, defaultValue, localize],
|
||||
);
|
||||
|
||||
const getDefaultDisplayValue = useCallback((): string => {
|
||||
if (defaultValue != null && enumMappings) {
|
||||
const stringDefault = String(defaultValue);
|
||||
if (stringDefault in enumMappings) {
|
||||
const mappedValue = String(enumMappings[stringDefault]);
|
||||
// Check if the mapped value is a localization key
|
||||
if (mappedValue.startsWith('com_')) {
|
||||
return localize(mappedValue as TranslationKeys) ?? mappedValue;
|
||||
}
|
||||
return mappedValue;
|
||||
}
|
||||
}
|
||||
return String(defaultValue ?? '');
|
||||
}, [defaultValue, enumMappings, localize]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: number) => {
|
||||
if (isEnum) {
|
||||
@@ -115,12 +160,12 @@ function DynamicSlider({
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`${settingKey}-dynamic-setting`}
|
||||
className="text-left text-sm font-medium"
|
||||
className="break-words text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
|
||||
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}: {defaultValue})
|
||||
({localize('com_endpoint_default')}: {getDefaultDisplayValue()})
|
||||
</small>
|
||||
)}
|
||||
</Label>
|
||||
@@ -132,13 +177,13 @@ function DynamicSlider({
|
||||
onChange={(value) => setInputValue(Number(value))}
|
||||
max={range ? range.max : (options?.length ?? 0) - 1}
|
||||
min={range ? range.min : 0}
|
||||
step={range ? range.step ?? 1 : 1}
|
||||
step={range ? (range.step ?? 1) : 1}
|
||||
controls={false}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 py-1 text-xs group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
@@ -146,13 +191,13 @@ function DynamicSlider({
|
||||
<Input
|
||||
id={`${settingKey}-dynamic-setting-input`}
|
||||
disabled={readonly}
|
||||
value={selectedValue ?? defaultValue}
|
||||
value={getDisplayValue(selectedValue)}
|
||||
onChange={() => ({})}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
'reset-rc-number-input h-auto w-14 border-0 py-1 pl-1 text-center text-xs group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
@@ -164,19 +209,23 @@ function DynamicSlider({
|
||||
value={[
|
||||
isEnum
|
||||
? enumToNumeric[(selectedValue as number) ?? '']
|
||||
: (inputValue as number) ?? (defaultValue as number),
|
||||
: ((inputValue as number) ?? (defaultValue as number)),
|
||||
]}
|
||||
onValueChange={(value) => handleValueChange(value[0])}
|
||||
onDoubleClick={() => setInputValue(defaultValue as string | number)}
|
||||
max={max}
|
||||
min={range ? range.min : 0}
|
||||
step={range ? range.step ?? 1 : 1}
|
||||
step={range ? (range.step ?? 1) : 1}
|
||||
className="flex h-4 w-full"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
description={
|
||||
descriptionCode
|
||||
? (localize(description as TranslationKeys) ?? description)
|
||||
: description
|
||||
}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -50,7 +50,7 @@ function DynamicSwitch({
|
||||
<div className="flex justify-between">
|
||||
<Label
|
||||
htmlFor={`${settingKey}-dynamic-switch`}
|
||||
className="text-left text-sm font-medium"
|
||||
className="break-words text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DynamicSwitch,
|
||||
DynamicInput,
|
||||
DynamicTags,
|
||||
DynamicSegment,
|
||||
} from './';
|
||||
|
||||
export const componentMapping: Record<
|
||||
@@ -23,4 +24,5 @@ export const componentMapping: Record<
|
||||
[ComponentTypes.Checkbox]: DynamicCheckbox,
|
||||
[ComponentTypes.Tags]: DynamicTags,
|
||||
[ComponentTypes.Combobox]: DynamicCombobox,
|
||||
[ComponentTypes.Segment]: DynamicSegment,
|
||||
};
|
||||
|
||||
@@ -6,4 +6,5 @@ export { default as DynamicSlider } from './DynamicSlider';
|
||||
export { default as DynamicSwitch } from './DynamicSwitch';
|
||||
export { default as DynamicInput } from './DynamicInput';
|
||||
export { default as DynamicTags } from './DynamicTags';
|
||||
export { default as DynamicSegment } from './DynamicSegment';
|
||||
export { default as OptionHoverAlt } from './OptionHover';
|
||||
|
||||
@@ -12,7 +12,10 @@ const CheckboxButton = React.forwardRef<
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
isCheckedClassName?: string;
|
||||
setValue?: (values: { e?: React.ChangeEvent<HTMLInputElement>; isChecked: boolean }) => void;
|
||||
setValue?: (values: {
|
||||
e?: React.ChangeEvent<HTMLInputElement>;
|
||||
value: boolean | string;
|
||||
}) => void;
|
||||
}
|
||||
>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => {
|
||||
const checkbox = useCheckboxStore();
|
||||
@@ -22,7 +25,7 @@ const CheckboxButton = React.forwardRef<
|
||||
if (typeof isChecked !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
setValue?.({ e, isChecked: !isChecked });
|
||||
setValue?.({ e, value: !isChecked });
|
||||
};
|
||||
|
||||
// Sync with controlled checked prop
|
||||
|
||||
169
client/src/components/ui/SegmentedControl.tsx
Normal file
169
client/src/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export interface SegmentedControlOption {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps {
|
||||
options: SegmentedControlOption[];
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
name?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
|
||||
({ options, value, onValueChange, name, className, disabled }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, height: 0, left: 0, top: 0 });
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [useGrid, setUseGrid] = useState(false);
|
||||
|
||||
// Ensure we always have a current value
|
||||
const currentValue = value !== undefined ? value : options[0]?.value;
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (disabled) return;
|
||||
onValueChange?.(newValue);
|
||||
};
|
||||
|
||||
const updateIndicator = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const selector = currentValue === '' ? '[data-value=""]' : `[data-value="${currentValue}"]`;
|
||||
const activeButton = containerRef.current.querySelector(selector) as HTMLButtonElement;
|
||||
|
||||
if (activeButton) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const buttonRect = activeButton.getBoundingClientRect();
|
||||
|
||||
if (useGrid) {
|
||||
// 2x2 grid layout - use full button dimensions
|
||||
setIndicatorStyle({
|
||||
width: buttonRect.width,
|
||||
height: buttonRect.height,
|
||||
left: buttonRect.left - containerRect.left,
|
||||
top: buttonRect.top - containerRect.top,
|
||||
});
|
||||
} else {
|
||||
// 1-row layout - account for flex-1 distribution
|
||||
const containerPadding = 4; // p-1 = 4px
|
||||
setIndicatorStyle({
|
||||
width: buttonRect.width,
|
||||
height: buttonRect.height,
|
||||
left: buttonRect.left - containerRect.left - containerPadding,
|
||||
top: buttonRect.top - containerRect.top - containerPadding,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if text is being truncated and switch to grid if needed
|
||||
const checkLayout = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const buttons = containerRef.current.querySelectorAll('button');
|
||||
let needsGrid = false;
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (button.scrollWidth > button.clientWidth) {
|
||||
needsGrid = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (needsGrid !== useGrid) {
|
||||
setUseGrid(needsGrid);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize and handle resize
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
checkLayout();
|
||||
updateIndicator();
|
||||
});
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
// Initial check
|
||||
setTimeout(() => {
|
||||
checkLayout();
|
||||
updateIndicator();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Update indicator when value changes
|
||||
useEffect(() => {
|
||||
updateIndicator();
|
||||
}, [currentValue, options]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative rounded-lg bg-surface-secondary p-1',
|
||||
useGrid ? 'grid grid-cols-2 gap-1' : 'flex items-center',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className,
|
||||
)}
|
||||
role="radiogroup"
|
||||
>
|
||||
{/* Sliding background indicator */}
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border-light/20 absolute rounded-md bg-surface-primary shadow-sm ring-1 transition-all duration-300 ease-out',
|
||||
!isInitialized && 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: indicatorStyle.width,
|
||||
height: indicatorStyle.height,
|
||||
transform: `translate(${indicatorStyle.left}px, ${indicatorStyle.top}px)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{options.map((option) => {
|
||||
const isActive = currentValue === option.value;
|
||||
const isDisabled = disabled || option.disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
disabled={isDisabled}
|
||||
data-value={option.value}
|
||||
onClick={() => handleChange(option.value)}
|
||||
className={cn(
|
||||
'relative z-10 px-2 py-1.5 text-xs font-medium transition-colors duration-200 ease-out',
|
||||
'rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'min-w-0 truncate',
|
||||
useGrid ? 'w-full' : 'flex-1',
|
||||
isActive ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary',
|
||||
!isDisabled && 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SegmentedControl.displayName = 'SegmentedControl';
|
||||
@@ -46,3 +46,4 @@ export { default as InputWithDropdown } from './InputWithDropDown';
|
||||
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
||||
export { SegmentedControl } from './SegmentedControl';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './mutations';
|
||||
@@ -1,73 +0,0 @@
|
||||
import { dataService, QueryKeys } from 'librechat-data-provider';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
|
||||
export const useCreateMCPMutation = (
|
||||
options?: t.CreateMCPMutationOptions,
|
||||
): UseMutationResult<t.MCP, Error, t.MCP> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(mcp: t.MCP) => {
|
||||
return dataService.createMCP(mcp);
|
||||
},
|
||||
{
|
||||
onMutate: (variables) => options?.onMutate?.(variables),
|
||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
|
||||
return prev ? [...prev, data] : [data];
|
||||
});
|
||||
|
||||
return options?.onSuccess?.(data, variables, context);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdateMCPMutation = (
|
||||
options?: t.UpdateMCPMutationOptions,
|
||||
): UseMutationResult<t.MCP, Error, { mcp_id: string; data: t.MCP }> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
({ mcp_id, data }: { mcp_id: string; data: t.MCP }) => {
|
||||
return dataService.updateMCP({ mcp_id, data });
|
||||
},
|
||||
{
|
||||
onMutate: (variables) => options?.onMutate?.(variables),
|
||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
|
||||
if (!prev) return prev;
|
||||
return prev.map((mcp) => (mcp.mcp_id === variables.mcp_id ? data : mcp));
|
||||
});
|
||||
return options?.onSuccess?.(data, variables, context);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeleteMCPMutation = (
|
||||
options?: t.DeleteMCPMutationOptions,
|
||||
): UseMutationResult<Record<string, unknown>, Error, { mcp_id: string }> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
({ mcp_id }: { mcp_id: string }) => {
|
||||
return dataService.deleteMCP({ mcp_id });
|
||||
},
|
||||
{
|
||||
onMutate: (variables) => options?.onMutate?.(variables),
|
||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.setQueryData<t.MCP[]>([QueryKeys.mcpServers], (prev) => {
|
||||
if (!prev) return prev;
|
||||
return prev.filter((mcp) => mcp.mcp_id !== variables.mcp_id);
|
||||
});
|
||||
return options?.onSuccess?.(data, variables, context);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ export * from './Memories';
|
||||
export * from './Messages';
|
||||
export * from './Misc';
|
||||
export * from './Tools';
|
||||
export * from './MCPs';
|
||||
export * from './connection';
|
||||
export * from './mutations';
|
||||
export * from './prompts';
|
||||
|
||||
@@ -2,15 +2,16 @@ import {
|
||||
QueryKeys,
|
||||
dataService,
|
||||
EModelEndpoint,
|
||||
isAgentsEndpoint,
|
||||
defaultOrderQuery,
|
||||
defaultAssistantsVersion,
|
||||
} from 'librechat-data-provider';
|
||||
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
InfiniteData,
|
||||
UseInfiniteQueryOptions,
|
||||
QueryObserverResult,
|
||||
UseQueryOptions,
|
||||
InfiniteData,
|
||||
} from '@tanstack/react-query';
|
||||
import type t from 'librechat-data-provider';
|
||||
import type {
|
||||
@@ -203,7 +204,7 @@ export const useAvailableToolsQuery = <TData = t.TPlugin[]>(
|
||||
const keyExpiry = queryClient.getQueryData<TCheckUserKeyResponse>([QueryKeys.name, endpoint]);
|
||||
const userProvidesKey = !!endpointsConfig?.[endpoint]?.userProvide;
|
||||
const keyProvided = userProvidesKey ? !!keyExpiry?.expiresAt : true;
|
||||
const enabled = !!endpointsConfig?.[endpoint] && keyProvided;
|
||||
const enabled = isAgentsEndpoint(endpoint) ? true : !!endpointsConfig?.[endpoint] && keyProvided;
|
||||
const version: string | number | undefined =
|
||||
endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint];
|
||||
return useQuery<t.TPlugin[], unknown, TData>(
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { default as useAgentsMap } from './useAgentsMap';
|
||||
export { default as useSelectAgent } from './useSelectAgent';
|
||||
export { default as useAgentCapabilities } from './useAgentCapabilities';
|
||||
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
|
||||
|
||||
61
client/src/hooks/Agents/useAgentCapabilities.ts
Normal file
61
client/src/hooks/Agents/useAgentCapabilities.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import { AgentCapabilities } from 'librechat-data-provider';
|
||||
|
||||
interface AgentCapabilitiesResult {
|
||||
toolsEnabled: boolean;
|
||||
actionsEnabled: boolean;
|
||||
artifactsEnabled: boolean;
|
||||
ocrEnabled: boolean;
|
||||
fileSearchEnabled: boolean;
|
||||
webSearchEnabled: boolean;
|
||||
codeEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function useAgentCapabilities(
|
||||
capabilities: AgentCapabilities[] | undefined,
|
||||
): AgentCapabilitiesResult {
|
||||
const toolsEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.tools) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const actionsEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.actions) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const artifactsEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.artifacts) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const ocrEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.ocr) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const fileSearchEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.file_search) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const webSearchEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.web_search) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const codeEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.execute_code) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
return {
|
||||
ocrEnabled,
|
||||
codeEnabled,
|
||||
toolsEnabled,
|
||||
actionsEnabled,
|
||||
artifactsEnabled,
|
||||
webSearchEnabled,
|
||||
fileSearchEnabled,
|
||||
};
|
||||
}
|
||||
35
client/src/hooks/Agents/useGetAgentsConfig.ts
Normal file
35
client/src/hooks/Agents/useGetAgentsConfig.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TAgentsEndpoint, TEndpointsConfig, TConfig } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
|
||||
interface UseGetAgentsConfigOptions {
|
||||
endpointsConfig?: TEndpointsConfig;
|
||||
}
|
||||
|
||||
export default function useGetAgentsConfig(options?: UseGetAgentsConfigOptions): {
|
||||
agentsConfig?: TAgentsEndpoint | null;
|
||||
endpointsConfig?: TEndpointsConfig | null;
|
||||
} {
|
||||
const { endpointsConfig: providedConfig } = options || {};
|
||||
|
||||
const { data: queriedConfig } = useGetEndpointsQuery({
|
||||
enabled: !providedConfig,
|
||||
});
|
||||
|
||||
const endpointsConfig = providedConfig || queriedConfig;
|
||||
|
||||
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
|
||||
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
|
||||
if (!config) return null;
|
||||
|
||||
return {
|
||||
...(config as TConfig),
|
||||
capabilities: Array.isArray(config.capabilities)
|
||||
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
|
||||
: ([] as AgentCapabilities[]),
|
||||
} as TAgentsEndpoint;
|
||||
}, [endpointsConfig]);
|
||||
|
||||
return { agentsConfig, endpointsConfig };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
QueryKeys,
|
||||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
isAgentsEndpoint,
|
||||
parseCompactConvo,
|
||||
replaceSpecialVars,
|
||||
isAssistantsEndpoint,
|
||||
@@ -24,7 +25,6 @@ import type { TAskFunction, ExtendedFile } from '~/common';
|
||||
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
|
||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||
import store, { useGetEphemeralAgent } from '~/store';
|
||||
import { getArtifactsMode } from '~/utils/artifacts';
|
||||
import { getEndpointField, logger } from '~/utils';
|
||||
import useUserKey from '~/hooks/Input/useUserKey';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -36,15 +36,6 @@ const logChatRequest = (request: Record<string, unknown>) => {
|
||||
logger.log('=====================================');
|
||||
};
|
||||
|
||||
const usesContentStream = (endpoint: EModelEndpoint | undefined, endpointType?: string) => {
|
||||
if (endpointType === EModelEndpoint.custom) {
|
||||
return true;
|
||||
}
|
||||
if (endpoint === EModelEndpoint.openAI || endpoint === EModelEndpoint.azureOpenAI) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export default function useChatFunctions({
|
||||
index = 0,
|
||||
files,
|
||||
@@ -76,9 +67,6 @@ export default function useChatFunctions({
|
||||
const setFilesToDelete = useSetFilesToDelete();
|
||||
const getEphemeralAgent = useGetEphemeralAgent();
|
||||
const isTemporary = useRecoilValue(store.isTemporary);
|
||||
const codeArtifacts = useRecoilValue(store.codeArtifacts);
|
||||
const includeShadcnui = useRecoilValue(store.includeShadcnui);
|
||||
const customPromptMode = useRecoilValue(store.customPromptMode);
|
||||
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
|
||||
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
|
||||
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
|
||||
@@ -93,7 +81,7 @@ export default function useChatFunctions({
|
||||
messageId = null,
|
||||
},
|
||||
{
|
||||
editedText = null,
|
||||
editedContent = null,
|
||||
editedMessageId = null,
|
||||
isResubmission = false,
|
||||
isRegenerate = false,
|
||||
@@ -195,10 +183,6 @@ export default function useChatFunctions({
|
||||
endpointType,
|
||||
overrideConvoId,
|
||||
overrideUserMessageId,
|
||||
artifacts:
|
||||
endpoint !== EModelEndpoint.agents
|
||||
? getArtifactsMode({ codeArtifacts, includeShadcnui, customPromptMode })
|
||||
: undefined,
|
||||
},
|
||||
convo,
|
||||
) as TEndpointOption;
|
||||
@@ -245,14 +229,11 @@ export default function useChatFunctions({
|
||||
setFilesToDelete({});
|
||||
}
|
||||
|
||||
const generation = editedText ?? latestMessage?.text ?? '';
|
||||
const responseText = isEditOrContinue ? generation : '';
|
||||
|
||||
const responseMessageId =
|
||||
editedMessageId ?? (latestMessage?.messageId ? latestMessage?.messageId + '_' : null) ?? null;
|
||||
const initialResponse: TMessage = {
|
||||
sender: responseSender,
|
||||
text: responseText,
|
||||
text: '',
|
||||
endpoint: endpoint ?? '',
|
||||
parentMessageId: isRegenerate ? messageId : intermediateId,
|
||||
messageId: responseMessageId ?? `${isRegenerate ? messageId : intermediateId}_`,
|
||||
@@ -272,34 +253,37 @@ export default function useChatFunctions({
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: {
|
||||
value: responseText,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (endpoint === EModelEndpoint.agents) {
|
||||
initialResponse.model = conversation?.agent_id ?? '';
|
||||
} else if (endpoint != null) {
|
||||
initialResponse.model = isAgentsEndpoint(endpoint)
|
||||
? (conversation?.agent_id ?? '')
|
||||
: (conversation?.model ?? '');
|
||||
initialResponse.text = '';
|
||||
initialResponse.content = [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: {
|
||||
value: responseText,
|
||||
|
||||
if (editedContent && latestMessage?.content) {
|
||||
initialResponse.content = cloneDeep(latestMessage.content);
|
||||
const { index, text, type } = editedContent;
|
||||
if (initialResponse.content && index >= 0 && index < initialResponse.content.length) {
|
||||
const contentPart = initialResponse.content[index];
|
||||
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
|
||||
contentPart[ContentTypes.THINK] = text;
|
||||
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
|
||||
contentPart[ContentTypes.TEXT] = text;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
initialResponse.content = [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: {
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
setShowStopButton(true);
|
||||
} else if (usesContentStream(endpoint, endpointType)) {
|
||||
initialResponse.text = '';
|
||||
initialResponse.content = [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: {
|
||||
value: responseText,
|
||||
},
|
||||
},
|
||||
];
|
||||
setShowStopButton(true);
|
||||
} else {
|
||||
];
|
||||
}
|
||||
setShowStopButton(true);
|
||||
}
|
||||
|
||||
@@ -316,7 +300,6 @@ export default function useChatFunctions({
|
||||
endpointOption,
|
||||
userMessage: {
|
||||
...currentMsg,
|
||||
generation,
|
||||
responseMessageId,
|
||||
overrideParentMessageId: isRegenerate ? messageId : null,
|
||||
},
|
||||
@@ -328,6 +311,7 @@ export default function useChatFunctions({
|
||||
initialResponse,
|
||||
isTemporary,
|
||||
ephemeralAgent,
|
||||
editedContent,
|
||||
};
|
||||
|
||||
if (isRegenerate) {
|
||||
|
||||
@@ -30,6 +30,14 @@ const useSetIndexOptions: TUseSetOptions = (preset = false) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-enable Responses API when web search is enabled
|
||||
if (param === 'web_search' && newValue === true) {
|
||||
const currentUseResponsesApi = conversation?.useResponsesApi ?? false;
|
||||
if (!currentUseResponsesApi) {
|
||||
update['useResponsesApi'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
setConversation(
|
||||
(prevState) =>
|
||||
tConvoUpdateSchema.parse({
|
||||
|
||||
@@ -25,10 +25,10 @@ import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
type UseFileHandling = {
|
||||
fileSetter?: FileSetter;
|
||||
fileFilter?: (file: File) => boolean;
|
||||
additionalMetadata?: Record<string, string | undefined>;
|
||||
overrideEndpoint?: EModelEndpoint;
|
||||
fileFilter?: (file: File) => boolean;
|
||||
overrideEndpointFileConfig?: EndpointFileConfig;
|
||||
additionalMetadata?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const useFileHandling = (params?: UseFileHandling) => {
|
||||
@@ -151,6 +151,10 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('endpoint', endpoint);
|
||||
formData.append(
|
||||
'original_endpoint',
|
||||
conversation?.endpointType || conversation?.endpoint || '',
|
||||
);
|
||||
formData.append('file', extendedFile.file as File, encodeURIComponent(filename));
|
||||
formData.append('file_id', extendedFile.file_id);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Blocks, MCPIcon, AttachmentIcon } from '~/components/svg';
|
||||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
|
||||
export default function useSideNavLinks({
|
||||
@@ -60,6 +61,7 @@ export default function useSideNavLinks({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const Links = useMemo(() => {
|
||||
const links: NavLink[] = [];
|
||||
@@ -150,13 +152,20 @@ export default function useSideNavLinks({
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
title: 'com_nav_mcp_panel',
|
||||
label: '',
|
||||
icon: MCPIcon,
|
||||
id: 'mcp-settings',
|
||||
Component: MCPPanel,
|
||||
});
|
||||
if (
|
||||
startupConfig?.mcpServers &&
|
||||
Object.values(startupConfig.mcpServers).some(
|
||||
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
|
||||
)
|
||||
) {
|
||||
links.push({
|
||||
title: 'com_nav_setting_mcp',
|
||||
label: '',
|
||||
icon: MCPIcon,
|
||||
id: 'mcp-settings',
|
||||
Component: MCPPanel,
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
title: 'com_sidepanel_hide_panel',
|
||||
@@ -180,6 +189,7 @@ export default function useSideNavLinks({
|
||||
hasAccessToBookmarks,
|
||||
hasAccessToCreateAgents,
|
||||
hidePanel,
|
||||
startupConfig,
|
||||
]);
|
||||
|
||||
return Links;
|
||||
|
||||
@@ -4,7 +4,14 @@ import { AuthType, Tools, QueryKeys } from 'librechat-data-provider';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
|
||||
export type SearchApiKeyFormData = {
|
||||
// Selected options
|
||||
selectedProvider: string;
|
||||
selectedReranker: string;
|
||||
selectedScraper: string;
|
||||
// API keys and URLs
|
||||
serperApiKey: string;
|
||||
searxngInstanceUrl: string;
|
||||
searxngApiKey: string;
|
||||
firecrawlApiKey: string;
|
||||
firecrawlApiUrl: string;
|
||||
jinaApiKey: string;
|
||||
@@ -42,6 +49,8 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => {
|
||||
(data: SearchApiKeyFormData) => {
|
||||
const auth = Object.entries({
|
||||
serperApiKey: data.serperApiKey,
|
||||
searxngInstanceUrl: data.searxngInstanceUrl,
|
||||
searxngApiKey: data.searxngApiKey,
|
||||
firecrawlApiKey: data.firecrawlApiKey,
|
||||
firecrawlApiUrl: data.firecrawlApiUrl,
|
||||
jinaApiKey: data.jinaApiKey,
|
||||
|
||||
@@ -19,12 +19,11 @@ export default function useSearchApiKeyForm({
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(data: SearchApiKeyFormData) => {
|
||||
reset();
|
||||
installTool(data);
|
||||
setIsDialogOpen(false);
|
||||
onSubmit?.();
|
||||
},
|
||||
[onSubmit, reset, installTool],
|
||||
[onSubmit, installTool],
|
||||
);
|
||||
|
||||
const handleRevokeApiKey = useCallback(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useCallback, useMemo, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { VerifyToolAuthResponse } from 'librechat-data-provider';
|
||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
@@ -19,9 +19,11 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return value !== undefined && value !== null && value !== '' && value !== false;
|
||||
return value !== undefined && value !== null;
|
||||
};
|
||||
|
||||
type ToolValue = boolean | string;
|
||||
|
||||
interface UseToolToggleOptions {
|
||||
conversationId?: string | null;
|
||||
toolKey: string;
|
||||
@@ -60,36 +62,52 @@ export function useToolToggle({
|
||||
[externalIsAuthenticated, authConfig, authQuery.data?.authenticated],
|
||||
);
|
||||
|
||||
const isToolEnabled = useMemo(() => {
|
||||
return ephemeralAgent?.[toolKey] ?? false;
|
||||
}, [ephemeralAgent, toolKey]);
|
||||
|
||||
/** Track previous value to prevent infinite loops */
|
||||
const prevIsToolEnabled = useRef(isToolEnabled);
|
||||
|
||||
const [toggleState, setToggleState] = useLocalStorage<boolean>(
|
||||
// Keep localStorage in sync
|
||||
const [, setLocalStorageValue] = useLocalStorage<ToolValue>(
|
||||
`${localStorageKey}${key}`,
|
||||
isToolEnabled,
|
||||
false,
|
||||
undefined,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
// The actual current value comes from ephemeralAgent
|
||||
const toolValue = useMemo(() => {
|
||||
return ephemeralAgent?.[toolKey] ?? false;
|
||||
}, [ephemeralAgent, toolKey]);
|
||||
|
||||
const isToolEnabled = useMemo(() => {
|
||||
// For backward compatibility, treat truthy string values as enabled
|
||||
if (typeof toolValue === 'string') {
|
||||
return toolValue.length > 0;
|
||||
}
|
||||
return toolValue === true;
|
||||
}, [toolValue]);
|
||||
|
||||
// Sync to localStorage when ephemeralAgent changes
|
||||
useEffect(() => {
|
||||
const value = ephemeralAgent?.[toolKey];
|
||||
if (value !== undefined) {
|
||||
setLocalStorageValue(value);
|
||||
}
|
||||
}, [ephemeralAgent, toolKey, setLocalStorageValue]);
|
||||
|
||||
const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ e, isChecked }: { e?: React.ChangeEvent<HTMLInputElement>; isChecked: boolean }) => {
|
||||
({ e, value }: { e?: React.ChangeEvent<HTMLInputElement>; value: ToolValue }) => {
|
||||
if (isAuthenticated !== undefined && !isAuthenticated && setIsDialogOpen) {
|
||||
setIsDialogOpen(true);
|
||||
e?.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
setToggleState(isChecked);
|
||||
|
||||
// Update ephemeralAgent (localStorage will sync automatically via effect)
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
[toolKey]: isChecked,
|
||||
...(prev || {}),
|
||||
[toolKey]: value,
|
||||
}));
|
||||
},
|
||||
[setToggleState, setIsDialogOpen, isAuthenticated, setEphemeralAgent, toolKey],
|
||||
[setIsDialogOpen, isAuthenticated, setEphemeralAgent, toolKey],
|
||||
);
|
||||
|
||||
const debouncedChange = useMemo(
|
||||
@@ -97,18 +115,12 @@ export function useToolToggle({
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevIsToolEnabled.current !== isToolEnabled) {
|
||||
setToggleState(isToolEnabled);
|
||||
}
|
||||
prevIsToolEnabled.current = isToolEnabled;
|
||||
}, [isToolEnabled, setToggleState]);
|
||||
|
||||
return {
|
||||
toggleState,
|
||||
toggleState: toolValue, // Return the actual value from ephemeralAgent
|
||||
handleChange,
|
||||
isToolEnabled,
|
||||
setToggleState,
|
||||
toolValue,
|
||||
setToggleState: (value: ToolValue) => handleChange({ value }), // Adapter for direct setting
|
||||
ephemeralAgent,
|
||||
debouncedChange,
|
||||
setEphemeralAgent,
|
||||
|
||||
@@ -68,7 +68,7 @@ const createErrorMessage = ({
|
||||
errorMetadata?: Partial<TMessage>;
|
||||
submission: EventSubmission;
|
||||
error?: Error | unknown;
|
||||
}) => {
|
||||
}): TMessage => {
|
||||
const currentMessages = getMessages();
|
||||
const latestMessage = currentMessages?.[currentMessages.length - 1];
|
||||
let errorMessage: TMessage;
|
||||
@@ -123,7 +123,7 @@ const createErrorMessage = ({
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
return tMessageSchema.parse(errorMessage);
|
||||
return tMessageSchema.parse(errorMessage) as TMessage;
|
||||
};
|
||||
|
||||
export const getConvoTitle = ({
|
||||
@@ -374,9 +374,6 @@ export default function useEventHandlers({
|
||||
});
|
||||
|
||||
let update = {} as TConversation;
|
||||
if (conversationId) {
|
||||
applyAgentTemplate(conversationId, submission.conversation.conversationId);
|
||||
}
|
||||
if (setConversation && !isAddedRequest) {
|
||||
setConversation((prevState) => {
|
||||
const parentId = isRegenerate ? userMessage.overrideParentMessageId : parentMessageId;
|
||||
@@ -411,6 +408,14 @@ export default function useEventHandlers({
|
||||
});
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
applyAgentTemplate(
|
||||
conversationId,
|
||||
submission.conversation.conversationId,
|
||||
submission.ephemeralAgent,
|
||||
);
|
||||
}
|
||||
|
||||
if (resetLatestMessage) {
|
||||
resetLatestMessage();
|
||||
}
|
||||
@@ -513,6 +518,15 @@ export default function useEventHandlers({
|
||||
}
|
||||
return update;
|
||||
});
|
||||
|
||||
if (conversation.conversationId && submission.ephemeralAgent) {
|
||||
applyAgentTemplate(
|
||||
conversation.conversationId,
|
||||
submissionConvo.conversationId,
|
||||
submission.ephemeralAgent,
|
||||
);
|
||||
}
|
||||
|
||||
if (location.pathname === '/c/new') {
|
||||
navigate(`/c/${conversation.conversationId}`, { replace: true });
|
||||
}
|
||||
@@ -521,18 +535,19 @@ export default function useEventHandlers({
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
[
|
||||
setShowStopButton,
|
||||
setCompleted,
|
||||
getMessages,
|
||||
announcePolite,
|
||||
navigate,
|
||||
genTitle,
|
||||
setConversation,
|
||||
isAddedRequest,
|
||||
setIsSubmitting,
|
||||
getMessages,
|
||||
setMessages,
|
||||
queryClient,
|
||||
setCompleted,
|
||||
isAddedRequest,
|
||||
announcePolite,
|
||||
setConversation,
|
||||
setIsSubmitting,
|
||||
setShowStopButton,
|
||||
location.pathname,
|
||||
navigate,
|
||||
applyAgentTemplate,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -550,7 +565,7 @@ export default function useEventHandlers({
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, convoId], finalMessages);
|
||||
};
|
||||
|
||||
const parseErrorResponse = (data: TResData | Partial<TMessage>) => {
|
||||
const parseErrorResponse = (data: TResData | Partial<TMessage>): TMessage => {
|
||||
const metadata = data['responseMessage'] ?? data;
|
||||
const errorMessage: Partial<TMessage> = {
|
||||
...initialResponse,
|
||||
@@ -563,7 +578,7 @@ export default function useEventHandlers({
|
||||
errorMessage.messageId = v4();
|
||||
}
|
||||
|
||||
return tMessageSchema.parse(errorMessage);
|
||||
return tMessageSchema.parse(errorMessage) as TMessage;
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
@@ -613,7 +628,7 @@ export default function useEventHandlers({
|
||||
...data,
|
||||
error: true,
|
||||
parentMessageId: userMessage.messageId,
|
||||
});
|
||||
}) as TMessage;
|
||||
|
||||
setErrorMessages(receivedConvoId, errorResponse);
|
||||
if (receivedConvoId && paramId === Constants.NEW_CONVO && newConversation) {
|
||||
|
||||
@@ -55,6 +55,26 @@ export default function useStepHandler({
|
||||
const messageMap = useRef(new Map<string, TMessage>());
|
||||
const stepMap = useRef(new Map<string, Agents.RunStep>());
|
||||
|
||||
const calculateContentIndex = (
|
||||
baseIndex: number,
|
||||
initialContent: TMessageContentParts[],
|
||||
incomingContentType: string,
|
||||
existingContent?: TMessageContentParts[],
|
||||
): number => {
|
||||
/** Only apply -1 adjustment for TEXT or THINK types when they match existing content */
|
||||
if (
|
||||
initialContent.length > 0 &&
|
||||
(incomingContentType === ContentTypes.TEXT || incomingContentType === ContentTypes.THINK)
|
||||
) {
|
||||
const targetIndex = baseIndex + initialContent.length - 1;
|
||||
const existingType = existingContent?.[targetIndex]?.type;
|
||||
if (existingType === incomingContentType) {
|
||||
return targetIndex;
|
||||
}
|
||||
}
|
||||
return baseIndex + initialContent.length;
|
||||
};
|
||||
|
||||
const updateContent = (
|
||||
message: TMessage,
|
||||
index: number,
|
||||
@@ -170,6 +190,11 @@ export default function useStepHandler({
|
||||
lastAnnouncementTimeRef.current = currentTime;
|
||||
}
|
||||
|
||||
let initialContent: TMessageContentParts[] = [];
|
||||
if (submission?.editedContent != null) {
|
||||
initialContent = submission?.initialResponse?.content ?? initialContent;
|
||||
}
|
||||
|
||||
if (event === 'on_run_step') {
|
||||
const runStep = data as Agents.RunStep;
|
||||
const responseMessageId = runStep.runId ?? '';
|
||||
@@ -189,7 +214,7 @@ export default function useStepHandler({
|
||||
parentMessageId: userMessage.messageId,
|
||||
conversationId: userMessage.conversationId,
|
||||
messageId: responseMessageId,
|
||||
content: [],
|
||||
content: initialContent,
|
||||
};
|
||||
|
||||
messageMap.current.set(responseMessageId, response);
|
||||
@@ -214,7 +239,9 @@ export default function useStepHandler({
|
||||
},
|
||||
};
|
||||
|
||||
updatedResponse = updateContent(updatedResponse, runStep.index, contentPart);
|
||||
/** Tool calls don't need index adjustment */
|
||||
const currentIndex = runStep.index + initialContent.length;
|
||||
updatedResponse = updateContent(updatedResponse, currentIndex, contentPart);
|
||||
});
|
||||
|
||||
messageMap.current.set(responseMessageId, updatedResponse);
|
||||
@@ -234,7 +261,9 @@ export default function useStepHandler({
|
||||
|
||||
const response = messageMap.current.get(responseMessageId);
|
||||
if (response) {
|
||||
const updatedResponse = updateContent(response, agent_update.index, data);
|
||||
// Agent updates don't need index adjustment
|
||||
const currentIndex = agent_update.index + initialContent.length;
|
||||
const updatedResponse = updateContent(response, currentIndex, data);
|
||||
messageMap.current.set(responseMessageId, updatedResponse);
|
||||
const currentMessages = getMessages() || [];
|
||||
setMessages([...currentMessages.slice(0, -1), updatedResponse]);
|
||||
@@ -255,7 +284,13 @@ export default function useStepHandler({
|
||||
? messageDelta.delta.content[0]
|
||||
: messageDelta.delta.content;
|
||||
|
||||
const updatedResponse = updateContent(response, runStep.index, contentPart);
|
||||
const currentIndex = calculateContentIndex(
|
||||
runStep.index,
|
||||
initialContent,
|
||||
contentPart.type || '',
|
||||
response.content,
|
||||
);
|
||||
const updatedResponse = updateContent(response, currentIndex, contentPart);
|
||||
|
||||
messageMap.current.set(responseMessageId, updatedResponse);
|
||||
const currentMessages = getMessages() || [];
|
||||
@@ -277,7 +312,13 @@ export default function useStepHandler({
|
||||
? reasoningDelta.delta.content[0]
|
||||
: reasoningDelta.delta.content;
|
||||
|
||||
const updatedResponse = updateContent(response, runStep.index, contentPart);
|
||||
const currentIndex = calculateContentIndex(
|
||||
runStep.index,
|
||||
initialContent,
|
||||
contentPart.type || '',
|
||||
response.content,
|
||||
);
|
||||
const updatedResponse = updateContent(response, currentIndex, contentPart);
|
||||
|
||||
messageMap.current.set(responseMessageId, updatedResponse);
|
||||
const currentMessages = getMessages() || [];
|
||||
@@ -318,7 +359,9 @@ export default function useStepHandler({
|
||||
contentPart.tool_call.expires_at = runStepDelta.delta.expires_at;
|
||||
}
|
||||
|
||||
updatedResponse = updateContent(updatedResponse, runStep.index, contentPart);
|
||||
/** Tool calls don't need index adjustment */
|
||||
const currentIndex = runStep.index + initialContent.length;
|
||||
updatedResponse = updateContent(updatedResponse, currentIndex, contentPart);
|
||||
});
|
||||
|
||||
messageMap.current.set(responseMessageId, updatedResponse);
|
||||
@@ -350,7 +393,9 @@ export default function useStepHandler({
|
||||
tool_call: result.tool_call,
|
||||
};
|
||||
|
||||
updatedResponse = updateContent(updatedResponse, runStep.index, contentPart, true);
|
||||
/** Tool calls don't need index adjustment */
|
||||
const currentIndex = runStep.index + initialContent.length;
|
||||
updatedResponse = updateContent(updatedResponse, currentIndex, contentPart, true);
|
||||
|
||||
messageMap.current.set(responseMessageId, updatedResponse);
|
||||
const updatedMessages = messages.map((msg) =>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"chat_direction_left_to_right": "شيء ما يحتاج أن يوضع هنا. كان فارغاً",
|
||||
"chat_direction_right_to_left": "شيء ما يحتاج أن يوضع هنا. كان فارغاً",
|
||||
"com_a11y_ai_composing": "الذكاء الاصطناعي ما زال يكتب",
|
||||
"com_a11y_end": "انتهى الذكاء الاصطناعي من الرد",
|
||||
"com_a11y_start": "بدأ الذكاء الاصطناعي بالرد",
|
||||
@@ -9,6 +11,9 @@
|
||||
"com_agents_create_error": "حدث خطأ أثناء إنشاء الوكيل الخاص بك",
|
||||
"com_agents_description_placeholder": "اختياري: اشرح عميلك هنا",
|
||||
"com_agents_enable_file_search": "تمكين البحث عن الملفات",
|
||||
"com_agents_file_context": "سياق الملف (قارئ الحروف البصري)",
|
||||
"com_agents_file_context_disabled": "يحب أولاً إنشاء الوكيل قبل رفع الملف لمحلل سياق الملف",
|
||||
"com_agents_file_context_info": "الملفات المرفوعة كـ \"سياق\" تتم معالجتها باستخدام قارئ الحروف البصري (OCR) لاستخراج النص، والذي يُضاف بعد ذلك إلى التعليمات الموجِهة للوكيل. مثالية للوثائق والصور التي تحتوي على نص أو ملفات PDF حيث تحتاج إلى المحتوى النصي الكامل للملف.",
|
||||
"com_agents_file_search_disabled": "يجب إنشاء الوكيل قبل تحميل الملفات للبحث في الملفات.",
|
||||
"com_agents_file_search_info": "عند التمكين، سيتم إعلام الوكيل بأسماء الملفات المدرجة أدناه بالضبط، مما يتيح له استرجاع السياق ذي الصلة من هذه الملفات.",
|
||||
"com_agents_instructions_placeholder": "التعليمات النظامية التي يستخدمها الوكيل",
|
||||
@@ -567,6 +572,8 @@
|
||||
"com_ui_fork_success": "تم تفريع المحادثة بنجاح",
|
||||
"com_ui_fork_visible": "الرسائل المرئية فقط",
|
||||
"com_ui_go_to_conversation": "انتقل إلى المحادثة",
|
||||
"com_ui_good_afternoon": "طاب يومك",
|
||||
"com_ui_good_morning": "صباح الخير",
|
||||
"com_ui_happy_birthday": "إنه عيد ميلادي الأول!",
|
||||
"com_ui_host": "مُضيف",
|
||||
"com_ui_image_gen": "توليد الصور",
|
||||
@@ -622,10 +629,17 @@
|
||||
"com_ui_prompts_allow_use": "السماح باستخدام الأوامر",
|
||||
"com_ui_provider": "مزود",
|
||||
"com_ui_read_aloud": "قراءة بصوت عالٍ",
|
||||
"com_ui_reference_saved_memories_description": "السماح للمساعد لاستخدام والرجوع لذكرياتك المخزنة عند الإجابة",
|
||||
"com_ui_regenerate": "إعادة توليد",
|
||||
"com_ui_regenerating": "جار إعادة التوليد...",
|
||||
"com_ui_region": "المنطقة",
|
||||
"com_ui_rename": "إعادة تسمية",
|
||||
"com_ui_rename_conversation": "إعادة تسمية المحادثة",
|
||||
"com_ui_rename_failed": "فشل في إعادة تسمية المحادثة",
|
||||
"com_ui_rename_prompt": "إعادة تسمية الأمر",
|
||||
"com_ui_requires_auth": "يتطلب مصادقة",
|
||||
"com_ui_reset_var": "إعادة تعيين {{0}}",
|
||||
"com_ui_reset_zoom": "إعادة تعيين التقريب",
|
||||
"com_ui_result": "النتيجة",
|
||||
"com_ui_revoke": "إلغاء",
|
||||
"com_ui_revoke_info": "إلغاء جميع بيانات الاعتماد المقدمة من المستخدم.",
|
||||
@@ -634,13 +648,20 @@
|
||||
"com_ui_revoke_keys": "إلغاء المفاتيح",
|
||||
"com_ui_revoke_keys_confirm": "هل أنت متأكد من أنك تريد إلغاء جميع المفاتيح؟",
|
||||
"com_ui_role_select": "الدور",
|
||||
"com_ui_roleplay": "القيام بالدور",
|
||||
"com_ui_run_code": "تنفيذ الشفرة",
|
||||
"com_ui_run_code_error": "حدث خطأ أثناء تشغيل الكود",
|
||||
"com_ui_run_code_error": "حدث خطأ أثناء تشغيل الشفرة",
|
||||
"com_ui_save": "حفظ",
|
||||
"com_ui_save_submit": "حفظ وإرسال",
|
||||
"com_ui_saved": "تم الحفظ!",
|
||||
"com_ui_saving": "جار الحفظ...",
|
||||
"com_ui_schema": "المخطط",
|
||||
"com_ui_scope": "مجال",
|
||||
"com_ui_search": "بحث",
|
||||
"com_ui_seconds": "ثواني",
|
||||
"com_ui_secret_key": "مفتاح سري",
|
||||
"com_ui_select": "اختر",
|
||||
"com_ui_select_all": "تحديد الكل",
|
||||
"com_ui_select_file": "اختر ملفًا",
|
||||
"com_ui_select_model": "اختر نموذجًا",
|
||||
"com_ui_select_provider": "اختر مزودًا",
|
||||
@@ -654,20 +675,34 @@
|
||||
"com_ui_share_create_message": "سيظل اسمك وأي رسائل تضيفها بعد المشاركة خاصة.",
|
||||
"com_ui_share_delete_error": "حدث خطأ أثناء حذف الرابط المشترك.",
|
||||
"com_ui_share_error": "حدث خطأ أثناء مشاركة رابط الدردشة",
|
||||
"com_ui_share_form_description": "شيء ما يحتاج أن يوضع هنا. كان فارغاً",
|
||||
"com_ui_share_link_to_chat": "شارك الرابط في الدردشة",
|
||||
"com_ui_share_to_all_users": "مشاركة مع جميع المستخدمين",
|
||||
"com_ui_share_update_message": "سيظل اسمك والتعليمات المخصصة وأي رسائل تضيفها بعد المشاركة خاصة.",
|
||||
"com_ui_share_var": "مشاركة {{0}}",
|
||||
"com_ui_shared_link_bulk_delete_success": "تم حذف الرابط المشترك بنجاح",
|
||||
"com_ui_shared_link_delete_success": "تم حذف الرابط المشترك بنجاح",
|
||||
"com_ui_shared_link_not_found": "الرابط المشترك غير موجود",
|
||||
"com_ui_shared_prompts": "المطالبات المشتركة",
|
||||
"com_ui_shop": "تسووق",
|
||||
"com_ui_show": "عرض",
|
||||
"com_ui_show_all": "عرض الكل",
|
||||
"com_ui_show_image_details": "إظهار تفاصيل الصورة",
|
||||
"com_ui_show_qr": "إظهار رمز الـ QR",
|
||||
"com_ui_sign_in_to_domain": "تسجيل الدخول الى {{0}}",
|
||||
"com_ui_simple": "بسيط",
|
||||
"com_ui_size": "الحجم",
|
||||
"com_ui_special_var_current_date": "التاريخ الآن",
|
||||
"com_ui_special_var_current_datetime": "التاريخ والوقت الآن",
|
||||
"com_ui_special_var_current_user": "المستخدم الحالي",
|
||||
"com_ui_special_variables": "المتغيرات الخاصة:",
|
||||
"com_ui_special_variables_more_info": "يمكنك اختيار متغيّر خاص من القائمة المنسدلة أدناه: {{current_date}}` (today's date and day of week), `{{current_datetime}}` (local date and time), `{{utc_iso_datetime}}` (UTC ISO datetime), و `{{current_user}}` (your account name).",
|
||||
"com_ui_speech_while_submitting": "لا يمكن إرسال الكلام أثناء إنشاء الرد",
|
||||
"com_ui_stop": "توقف",
|
||||
"com_ui_storage": "التخزين",
|
||||
"com_ui_submit": "إرسال",
|
||||
"com_ui_teach_or_explain": "علِّم",
|
||||
"com_ui_temporary": "دردشة مؤقتة",
|
||||
"com_ui_terms_and_conditions": "شروط الخدمة",
|
||||
"com_ui_terms_of_service": "شروط الخدمة",
|
||||
"com_ui_tools": "أدوات المساعدين",
|
||||
@@ -695,6 +730,5 @@
|
||||
"com_ui_versions": "الإصدارات",
|
||||
"com_ui_yes": "نعم",
|
||||
"com_ui_zoom": "تكبير",
|
||||
"com_user_message": "أنت",
|
||||
"com_warning_resubmit_unsupported": "إعادة إرسال رسالة الذكاء الاصطناعي غير مدعومة لنقطة النهاية هذه"
|
||||
"com_user_message": "أنت"
|
||||
}
|
||||
@@ -322,7 +322,6 @@
|
||||
"com_nav_delete_cache_storage": "Esborra la memòria cau de TTS",
|
||||
"com_nav_delete_data_info": "Totes les teves dades s'eliminaran.",
|
||||
"com_nav_delete_warning": "AVÍS: Això eliminarà permanentment el teu compte.",
|
||||
"com_nav_edit_chat_badges": "Edita les insígnies del xat",
|
||||
"com_nav_enable_cache_tts": "Habilita la memòria cau TTS",
|
||||
"com_nav_enable_cloud_browser_voice": "Utilitza veus al núvol",
|
||||
"com_nav_enabled": "Habilitat",
|
||||
@@ -688,7 +687,6 @@
|
||||
"com_ui_import_conversation_info": "Importa converses des d'un fitxer JSON",
|
||||
"com_ui_import_conversation_success": "Converses importades amb èxit",
|
||||
"com_ui_include_shadcnui": "Inclou instruccions de components shadcn/ui",
|
||||
"com_ui_include_shadcnui_agent": "Inclou instruccions shadcn/ui",
|
||||
"com_ui_input": "Entrada",
|
||||
"com_ui_instructions": "Instruccions",
|
||||
"com_ui_late_night": "Bona matinada",
|
||||
@@ -868,6 +866,5 @@
|
||||
"com_ui_x_selected": "{{0}} seleccionats",
|
||||
"com_ui_yes": "Sí",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Tu",
|
||||
"com_warning_resubmit_unsupported": "Tornar a enviar el missatge de la IA no està suportat per aquest endpoint."
|
||||
"com_user_message": "Tu"
|
||||
}
|
||||
@@ -720,6 +720,5 @@
|
||||
"com_ui_write": "Psát",
|
||||
"com_ui_yes": "Ano",
|
||||
"com_ui_zoom": "Přiblížit",
|
||||
"com_user_message": "Vy",
|
||||
"com_warning_resubmit_unsupported": "Opětovné odeslání AI zprávy není pro tento koncový bod podporováno."
|
||||
"com_user_message": "Vy"
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"com_agents_name_placeholder": "Valgfrit: Navnet på agenten",
|
||||
"com_agents_no_access": "Du har ikke adgang til at redigere denne agent.",
|
||||
"com_agents_not_available": "Agent ikke tilgængelig",
|
||||
"com_agents_search_info": "Når det er aktiveret, kan din agent søge på nettet efter opdaterede oplysninger. Kræver en gyldig API-nøgle.",
|
||||
"com_agents_search_name": "Søg agenter efter navn",
|
||||
"com_agents_update_error": "Der opstod en fejl ved opdateringen af din agent.",
|
||||
"com_assistants_action_attempt": "Assistenten vil tale med {{0}}",
|
||||
@@ -38,6 +39,7 @@
|
||||
"com_assistants_code_interpreter": "Kodefortolker",
|
||||
"com_assistants_code_interpreter_files": "Filerne nedenfor er kun til Kodefortolker:",
|
||||
"com_assistants_code_interpreter_info": "Kodefortolkeren gør det muligt for assistenten at skrive og køre kode. Dette værktøj kan behandle filer med forskellige data og formateringer og generere filer såsom grafer.",
|
||||
"com_assistants_completed_action": "Talte med {{0}}",
|
||||
"com_assistants_completed_function": "Kørte {{0}}",
|
||||
"com_assistants_conversation_starters": "Samtalestartere",
|
||||
"com_assistants_conversation_starters_placeholder": "Indtast en samtalestarter",
|
||||
@@ -48,6 +50,7 @@
|
||||
"com_assistants_description_placeholder": "Valgfrit: Beskriv din assistent her",
|
||||
"com_assistants_domain_info": "Assistenten sendte disse oplysninger til {{0}}",
|
||||
"com_assistants_file_search": "Filsøgning",
|
||||
"com_assistants_file_search_info": "Filsøgning giver assistenten mulighed for at hente viden fra filer, som du eller dine brugere uploader. Når en fil er uploadet, beslutter assistenten automatisk, hvornår der skal hentes indhold baseret på brugeranmodninger. Vedhæftning af vektorlagre til filsøgning er endnu ikke understøttet. Du kan vedhæfte dem fra Provider Playground eller vedhæfte filer til beskeder til filsøgning på trådbasis.",
|
||||
"com_assistants_function_use": "Assistent brugt {{0}}",
|
||||
"com_assistants_image_vision": "Billedvision",
|
||||
"com_assistants_instructions_placeholder": "De systeminstruktioner, som assistenten bruger",
|
||||
@@ -59,6 +62,7 @@
|
||||
"com_assistants_non_retrieval_model": "Filsøgning er ikke aktiveret på denne model. Vælg venligst en anden model.",
|
||||
"com_assistants_retrieval": "Hentning",
|
||||
"com_assistants_running_action": "Afvikler handling",
|
||||
"com_assistants_running_var": "Kører {{0}}",
|
||||
"com_assistants_search_name": "Søg assistenter efter navn",
|
||||
"com_assistants_update_actions_error": "Der opstod en fejl ved oprettelse eller opdatering af handlingen.",
|
||||
"com_assistants_update_actions_success": "Vellykket oprettet eller opdateret Handling",
|
||||
@@ -120,6 +124,7 @@
|
||||
"com_auth_reset_password_if_email_exists": "Hvis der findes en konto med denne e-mail, er der sendt en e-mail med instruktioner til nulstilling af adgangskode. Sørg for at tjekke din spam-mappe.",
|
||||
"com_auth_reset_password_link_sent": "E-mail sendt",
|
||||
"com_auth_reset_password_success": "Nulstilling af adgangskode genemført",
|
||||
"com_auth_saml_login": "Fortsæt med SAML",
|
||||
"com_auth_sign_in": "Log ind",
|
||||
"com_auth_sign_up": "Tilmeld dig",
|
||||
"com_auth_submit_registration": "Send registrering",
|
||||
@@ -131,6 +136,8 @@
|
||||
"com_auth_username_min_length": "Brugernavn skal være mindst 2 tegn",
|
||||
"com_auth_verify_your_identity": "Bekræft din identitet",
|
||||
"com_auth_welcome_back": "Velkommen tilbage",
|
||||
"com_citation_more_details": "Flere detaljer om {{label}}",
|
||||
"com_citation_source": "Kilde",
|
||||
"com_click_to_download": "(klik her for at downloade)",
|
||||
"com_download_expired": "(download udløbet)",
|
||||
"com_download_expires": "(klik her for at downloade - udløber {{0}})",
|
||||
@@ -142,6 +149,10 @@
|
||||
"com_endpoint_anthropic_maxoutputtokens": "Maksimalt antal tokens, der kan genereres i svaret. Angiv en lavere værdi for kortere svar og en højere værdi for længere svar. Bemærk: Modeller kan stoppe, før de når dette maksimum.",
|
||||
"com_endpoint_anthropic_prompt_cache": "Prompt caching gør det muligt at genbruge store kontekster eller instruktioner på tværs af API-kald, hvilket reducerer omkostninger og ventetid.",
|
||||
"com_endpoint_anthropic_temp": "Spænder fra 0 til 1. Brug temp tættere på 0 til analytiske/ multiple choice-opgaver og tættere på 1 til kreative og generative opgaver. Vi anbefaler at ændre dette eller Top P, men ikke begge dele.",
|
||||
"com_endpoint_anthropic_thinking": "Aktiverer intern ræsonnering for understøttede Claude-modeller (3.7 Sonnet). Bemærk: kræver, at \"Thinking Budget\" er indstillet og lavere end \"Max Output Tokens\"",
|
||||
"com_endpoint_anthropic_thinking_budget": "Bestemmer det maksimale antal tokens, som Claude må bruge til sin interne ræsonnementsproces. Større budgetter kan forbedre kvaliteten af svarene ved at muliggøre en mere grundig analyse af komplekse problemer, selv om Claude måske ikke bruger hele det tildelte budget, især ved intervaller over 32K. Denne indstilling skal være lavere end \"Max Output Tokens\".",
|
||||
"com_endpoint_anthropic_topk": "Top-k ændrer, hvordan modellen udvælger symboler til output. En top-k på 1 betyder, at det valgte token er det mest sandsynlige blandt alle tokens i modellens ordforråd (også kaldet grådig afkodning), mens en top-k på 3 betyder, at det næste token vælges blandt de 3 mest sandsynlige tokens (ved hjælp af temperatur).",
|
||||
"com_endpoint_anthropic_topp": "Top-p ændrer, hvordan modellen udvælger tokens til output. Tokens vælges fra de mest K (se topK-parameter) sandsynlige til de mindst sandsynlige, indtil summen af deres sandsynligheder er lig med top-p-værdien.",
|
||||
"com_endpoint_assistant": "Assistent",
|
||||
"com_endpoint_assistant_model": "Assistentmodel",
|
||||
"com_endpoint_assistant_placeholder": "Vælg en assistent fra sidepanelet til højre",
|
||||
@@ -154,6 +165,7 @@
|
||||
"com_endpoint_config_google_gemini_api": "(Gemini API)",
|
||||
"com_endpoint_config_google_service_key": "Google Service Konto -nøgle",
|
||||
"com_endpoint_config_key": "Angiv API-nøgle",
|
||||
"com_endpoint_config_key_encryption": "Din nøgle vil blive krypteret og slettet den",
|
||||
"com_endpoint_config_key_for": "Indstil API-nøgle til",
|
||||
"com_endpoint_config_key_google_need_to": "Du er nødt til at",
|
||||
"com_endpoint_config_key_google_service_account": "Opret en servicekonto",
|
||||
@@ -186,6 +198,8 @@
|
||||
"com_endpoint_google_custom_name_placeholder": "Indstil et brugerdefineret navn til Google",
|
||||
"com_endpoint_google_maxoutputtokens": "Maksimalt antal tokens, der kan genereres i svaret. Angiv en lavere værdi for kortere svar og en højere værdi for længere svar. Bemærk: Modeller kan stoppe, før de når dette maksimum.",
|
||||
"com_endpoint_google_temp": "Højere værdier = mere tilfældige, mens lavere værdier = mere fokuserede og deterministiske. Vi anbefaler at ændre dette eller Top P, men ikke begge dele.",
|
||||
"com_endpoint_google_topk": "Top-k ændrer, hvordan modellen udvælger symboler til output. En top-k på 1 betyder, at det valgte token er det mest sandsynlige blandt alle tokens i modellens ordforråd (også kaldet grådig afkodning), mens en top-k på 3 betyder, at det næste token vælges blandt de 3 mest sandsynlige tokens (ved hjælp af temperatur).",
|
||||
"com_endpoint_google_topp": "Top-p ændrer, hvordan modellen udvælger tokens til output. Tokens vælges fra de mest K (se topK-parameter) sandsynlige til de mindst sandsynlige, indtil summen af deres sandsynligheder er lig med top-p-værdien.",
|
||||
"com_endpoint_instructions_assistants": "Overskriv instruktioner",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Overstyrer instruktionerne fra assistenten. Det er nyttigt, hvis man vil ændre adfærden for hver enkelt kørsel.",
|
||||
"com_endpoint_max_output_tokens": "Maks. output-tokens",
|
||||
@@ -196,6 +210,18 @@
|
||||
"com_endpoint_no_presets": "Ingen forudindstillinger endnu, brug indstillingsknappen til at oprette en",
|
||||
"com_endpoint_open_menu": "Åbn menu",
|
||||
"com_endpoint_openai_custom_name_placeholder": "Indstil et brugerdefineret navn til AI'en",
|
||||
"com_endpoint_openai_detail": "Opløsningen for Vision-anmodninger. \"Lav\" er billigere og hurtigere, \"Høj\" er mere detaljeret og dyrere, og \"Auto\" vælger automatisk mellem de to baseret på billedopløsningen.",
|
||||
"com_endpoint_openai_freq": "Tal mellem -2,0 og 2,0. Positive værdier straffer nye tokens baseret på deres eksisterende frekvens i teksten indtil videre, hvilket mindsker modellens sandsynlighed for at gentage den samme linje ordret.",
|
||||
"com_endpoint_openai_max": "Det maksimale antal tokens, der skal genereres. Den samlede længde af input-tokens og genererede tokens er begrænset af modellens kontekstlængde.",
|
||||
"com_endpoint_openai_max_tokens": "Valgfrit 'max_tokens'-felt, der repræsenterer det maksimale antal tokens, der kan genereres i chatudfyldningen. Den samlede længde af input-tokens og genererede tokens er begrænset af modellernes kontekstlængde. Du kan opleve fejl, hvis dette antal overskrider det maksimale antal kontekst-tokens.",
|
||||
"com_endpoint_openai_pres": "Tal mellem -2,0 og 2,0. Positive værdier straffer nye tokens baseret på, om de optræder i teksten indtil videre, hvilket øger modellens sandsynlighed for at tale om nye emner.",
|
||||
"com_endpoint_openai_prompt_prefix_placeholder": "Indstil brugerdefinerede instruktioner, der skal inkluderes i systembeskeden. Standard: ingen",
|
||||
"com_endpoint_openai_reasoning_effort": "Kun o1- og o3-modeller: Begrænser indsatsen for ræsonnement for ræsonnerende modeller. At reducere ræsonneringsindsatsen kan resultere i hurtigere svar og færre tokens brugt på ræsonnering i et svar.",
|
||||
"com_endpoint_openai_resend": "Send alle tidligere vedhæftede billeder igen. Bemærk: Dette kan øge tokenomkostningerne betydeligt, og du kan opleve fejl med mange vedhæftede billeder.",
|
||||
"com_endpoint_openai_resend_files": "Send alle tidligere vedhæftede filer igen. Bemærk: Dette vil øge tokenomkostningerne, og du kan opleve fejl med mange vedhæftede filer.",
|
||||
"com_endpoint_openai_stop": "Op til 4 sekvenser, hvor API'en stopper med at generere yderligere tokens.",
|
||||
"com_endpoint_openai_temp": "Højere værdier = mere tilfældige, mens lavere værdier = mere fokuserede og deterministiske. Vi anbefaler at ændre dette eller Top P, men ikke begge dele.",
|
||||
"com_endpoint_openai_topp": "Et alternativ til sampling med temperatur, kaldet nucleus sampling, hvor modellen tager højde for resultaterne af tokens med top_p sandsynlighedsmasse. Så 0,1 betyder, at kun de tokens, der har den største sandsynlighedsmasse på 10 %, tages i betragtning. Vi anbefaler at ændre dette eller temperaturen, men ikke begge dele.",
|
||||
"com_endpoint_output": "Produktion",
|
||||
"com_endpoint_plug_image_detail": "Billeddetaljer",
|
||||
"com_endpoint_plug_resend_files": "Send filer igen",
|
||||
@@ -249,7 +275,10 @@
|
||||
"com_error_files_upload": "Der opstod en fejl under upload af filen.",
|
||||
"com_error_files_upload_canceled": "Anmodningen om filoverførsel blev annulleret. Bemærk: Filuploaden kan stadig være under behandling og skal slettes manuelt.",
|
||||
"com_error_files_validation": "Der opstod en fejl under validering af filen.",
|
||||
"com_error_input_length": "Antallet af tokener i den seneste meddelelse er for langt og overskrider tokengrænsen, eller dine tokengrænseparametre er forkert konfigureret, hvilket påvirker kontekstvinduet negativt. Mere information: {{0}}. Forkort venligst din besked, juster den maksimale kontekststørrelse fra samtaleparametrene, eller forgren samtalen for at fortsætte.",
|
||||
"com_error_invalid_agent_provider": "Den \"{{0}}\"-udbyder er ikke tilgængelig for brug med agenter. Gå til din agents indstillinger, og vælg en aktuelt tilgængelig udbyder.",
|
||||
"com_error_invalid_user_key": "Ugyldig nøgle angivet. Angiv venligst en gyldig nøgle, og prøv igen.",
|
||||
"com_error_moderation": "Det ser ud til, at det indsendte indhold er blevet markeret af vores moderationssystem for ikke at være i overensstemmelse med vores retningslinjer for fællesskabet. Vi kan ikke gå videre med dette specifikke emne. Hvis du har andre spørgsmål eller emner, du gerne vil udforske, kan du redigere din besked eller oprette en ny samtale.",
|
||||
"com_error_no_base_url": "Ingen base-URL fundet. Angiv venligst en og prøv igen.",
|
||||
"com_error_no_user_key": "Ingen nøgle fundet. Angiv venligst en nøgle, og prøv igen.",
|
||||
"com_files_filter": "Filtrer filer ...",
|
||||
@@ -275,6 +304,27 @@
|
||||
"com_nav_auto_transcribe_audio": "Automatisk transskribering af lyd",
|
||||
"com_nav_automatic_playback": "Autoplay Seneste besked",
|
||||
"com_nav_balance": "Balance",
|
||||
"com_nav_balance_auto_refill_disabled": "Automatisk genopfyldning er deaktiveret.",
|
||||
"com_nav_balance_auto_refill_error": "Fejl ved indlæsning af indstillinger for automatisk genopfyldning.",
|
||||
"com_nav_balance_auto_refill_settings": "Indstillinger for automatisk genopfyldning",
|
||||
"com_nav_balance_day": "dag",
|
||||
"com_nav_balance_days": "dage",
|
||||
"com_nav_balance_every": "Hver",
|
||||
"com_nav_balance_hour": "time",
|
||||
"com_nav_balance_hours": "timer",
|
||||
"com_nav_balance_interval": "Interval:",
|
||||
"com_nav_balance_last_refill": "Sidste genopfyldning:",
|
||||
"com_nav_balance_minute": "minut",
|
||||
"com_nav_balance_minutes": "minutter",
|
||||
"com_nav_balance_month": "måned",
|
||||
"com_nav_balance_months": "måneder",
|
||||
"com_nav_balance_next_refill": "Næste genopfyldning:",
|
||||
"com_nav_balance_next_refill_info": "Den næste genopfyldning sker kun automatisk, når begge betingelser er opfyldt: Det angivne tidsinterval er gået siden sidste genopfyldning, og hvis du sender en besked, vil din saldo falde til under nul.",
|
||||
"com_nav_balance_refill_amount": "Genopfyldningsmængde:",
|
||||
"com_nav_balance_second": "anden",
|
||||
"com_nav_balance_seconds": "sekunder",
|
||||
"com_nav_balance_week": "uge",
|
||||
"com_nav_balance_weeks": "uger",
|
||||
"com_nav_browser": "Browser",
|
||||
"com_nav_center_chat_input": "Center Chat Input på velkomstskærmen",
|
||||
"com_nav_change_picture": "Skift billede",
|
||||
@@ -298,7 +348,6 @@
|
||||
"com_nav_delete_cache_storage": "Slet TTS-cache-lagring",
|
||||
"com_nav_delete_data_info": "Alle dine data vil blive slettet.",
|
||||
"com_nav_delete_warning": "ADVARSEL: Dette vil slette din konto permanent.",
|
||||
"com_nav_edit_chat_badges": "Rediger chat-badges",
|
||||
"com_nav_enable_cache_tts": "Aktivér cache-TTS",
|
||||
"com_nav_enable_cloud_browser_voice": "Brug cloud-baserede stemmer",
|
||||
"com_nav_enabled": "Aktiveret",
|
||||
@@ -325,18 +374,31 @@
|
||||
"com_nav_info_code_artifacts": "Aktiverer visning af eksperimentelle kodeartefakter ved siden af chatten",
|
||||
"com_nav_info_code_artifacts_agent": "Aktiverer brugen af kodeartefakter for denne agent. Som standard tilføjes yderligere instruktioner specifikke for brugen af artefakter, medmindre \"Brugerdefineret prompttilstand\" er aktiveret.",
|
||||
"com_nav_info_custom_prompt_mode": "Når den er aktiveret, vil standardsystemprompten for artefakter ikke blive inkluderet. Alle instruktioner til generering af artefakter skal angives manuelt i denne tilstand.",
|
||||
"com_nav_info_enter_to_send": "Når den er aktiveret, sender du din besked ved at trykke på `ENTER`. Når den er deaktiveret, vil et tryk på Enter tilføje en ny linje, og du skal trykke på `CTRL + ENTER` / `⌘ + ENTER` for at sende din besked.",
|
||||
"com_nav_info_fork_change_default": "`Kun synlige beskeder` inkluderer kun den direkte sti til den valgte besked. `Include related branches` tilføjer grene langs stien. `Include all to/from here` inkluderer alle forbundne beskeder og grene.",
|
||||
"com_nav_info_fork_split_target_setting": "Når den er aktiveret, vil forking begynde fra målbeskeden til den seneste besked i samtalen i henhold til den valgte adfærd.",
|
||||
"com_nav_info_include_shadcnui": "Når det er aktiveret, vil der blive inkluderet instruktioner til brug af shadcn/ui-komponenter. shadcn/ui er en samling af genanvendelige komponenter, der er bygget med Radix UI og Tailwind CSS. Bemærk: Det er lange instruktioner, og du bør kun aktivere dem, hvis det er vigtigt for dig at informere LLM om de korrekte importer og komponenter. For mere information om disse komponenter, besøg: https://ui.shadcn.com/",
|
||||
"com_nav_info_latex_parsing": "Når det er aktiveret, vil LaTeX-kode i meddelelser blive gengivet som matematiske ligninger. Hvis du deaktiverer dette, kan det forbedre ydeevnen, hvis du ikke har brug for LaTeX-rendering.",
|
||||
"com_nav_info_save_badges_state": "Når den er aktiveret, gemmes chatbadgenes tilstand. Det betyder, at hvis du opretter en ny chat, vil badges forblive i samme tilstand som i den forrige chat. Hvis du deaktiverer denne mulighed, vil badges blive nulstillet til deres standardtilstand, hver gang du opretter en ny chat.",
|
||||
"com_nav_info_save_draft": "Når det er aktiveret, gemmes den tekst og de vedhæftede filer, du indtaster i chatformularen, automatisk lokalt som kladder. Disse kladder vil være tilgængelige, selv om du genindlæser siden eller skifter til en anden samtale. Kladder gemmes lokalt på din enhed og slettes, når beskeden er sendt.",
|
||||
"com_nav_info_show_thinking": "Når den er aktiveret, vil chatten som standard vise de tænkende dropdowns åbne, så du kan se AI'ens ræsonnementer i realtid. Når den er deaktiveret, forbliver de tænkende dropdowns lukket som standard for at give en renere og mere strømlinet grænseflade.",
|
||||
"com_nav_info_user_name_display": "Når den er aktiveret, vises afsenderens brugernavn over hver besked, du sender. Når det er deaktiveret, vil du kun se \"Du\" over dine beskeder.",
|
||||
"com_nav_lang_arabic": "Arabisk",
|
||||
"com_nav_lang_auto": "Automatisk detektion",
|
||||
"com_nav_lang_brazilian_portuguese": "Portugisisk Brasiliansk",
|
||||
"com_nav_lang_catalan": "Catalansk",
|
||||
"com_nav_lang_chinese": "Kinesisk",
|
||||
"com_nav_lang_czech": "Tjekkisk",
|
||||
"com_nav_lang_danish": "Dansk",
|
||||
"com_nav_lang_dutch": "Hollandsk",
|
||||
"com_nav_lang_english": "Engelsk",
|
||||
"com_nav_lang_estonian": "Estisk",
|
||||
"com_nav_lang_finnish": "Finsk",
|
||||
"com_nav_lang_french": "Fransk ",
|
||||
"com_nav_lang_georgian": "Georgisk",
|
||||
"com_nav_lang_german": "Tysk",
|
||||
"com_nav_lang_hebrew": "Hebraisk",
|
||||
"com_nav_lang_hungarian": "Ungarsk",
|
||||
"com_nav_lang_indonesia": "Indonesien",
|
||||
"com_nav_lang_italian": "Italiensk",
|
||||
"com_nav_lang_japanese": "Japansk",
|
||||
@@ -350,6 +412,7 @@
|
||||
"com_nav_lang_thai": "Thai",
|
||||
"com_nav_lang_traditional_chinese": "Kinesisk",
|
||||
"com_nav_lang_turkish": "Tyrkisk",
|
||||
"com_nav_lang_vietnamese": "Vietnamesisk",
|
||||
"com_nav_language": "Sprog",
|
||||
"com_nav_latex_parsing": "Parsing af LaTeX i beskeder (kan påvirke ydeevnen)",
|
||||
"com_nav_log_out": "Log ud",
|
||||
@@ -374,6 +437,7 @@
|
||||
"com_nav_search_placeholder": "Søg efter beskeder",
|
||||
"com_nav_send_message": "Send besked",
|
||||
"com_nav_setting_account": "Konto",
|
||||
"com_nav_setting_balance": "Balance",
|
||||
"com_nav_setting_beta": "Beta-funktioner",
|
||||
"com_nav_setting_chat": "Chat",
|
||||
"com_nav_setting_data": "Datakontrol",
|
||||
@@ -411,6 +475,12 @@
|
||||
"com_sidepanel_hide_panel": "Skjul panel",
|
||||
"com_sidepanel_manage_files": "Administrer filer",
|
||||
"com_sidepanel_parameters": "Parametre",
|
||||
"com_sources_image_alt": "Søgeresultatbillede",
|
||||
"com_sources_more_sources": "+{{count}} kilder",
|
||||
"com_sources_tab_all": "Alle",
|
||||
"com_sources_tab_images": "Billeder",
|
||||
"com_sources_tab_news": "Nyheder",
|
||||
"com_sources_title": "Kilder",
|
||||
"com_ui_2fa_account_security": "To-faktor-autentificering tilføjer et ekstra lag af sikkerhed til din konto",
|
||||
"com_ui_2fa_disable": "Deaktiver 2FA",
|
||||
"com_ui_2fa_disable_error": "Der opstod en fejl ved deaktivering af to-faktor-autentificering",
|
||||
@@ -445,6 +515,20 @@
|
||||
"com_ui_agent_recursion_limit_info": "Begrænser, hvor mange trin agenten kan tage i en kørsel, før den giver et endeligt svar. Standard er 25 trin. Et trin er enten en AI API-anmodning eller en værktøjsbrugsrunde. For eksempel tager en grundlæggende værktøjsinteraktion 3 trin: indledende anmodning, værktøjsbrug og opfølgende anmodning.",
|
||||
"com_ui_agent_shared_to_all": "Der skal stå noget her. Det var tomt.",
|
||||
"com_ui_agent_var": "{{0}} agent",
|
||||
"com_ui_agent_version": "Version",
|
||||
"com_ui_agent_version_active": "Aktiv version",
|
||||
"com_ui_agent_version_duplicate": "Duplikatversion fundet. Dette vil skabe en version, der er identisk med Version {{versionIndex}}.",
|
||||
"com_ui_agent_version_empty": "Ingen tilgængelige versioner",
|
||||
"com_ui_agent_version_error": "Fejl ved hentning af versioner",
|
||||
"com_ui_agent_version_history": "Versionshistorik",
|
||||
"com_ui_agent_version_no_agent": "Ingen agent valgt. Vælg venligst en agent for at se versionshistorikken.",
|
||||
"com_ui_agent_version_no_date": "Dato ikke tilgængelig",
|
||||
"com_ui_agent_version_restore": "Gendan",
|
||||
"com_ui_agent_version_restore_confirm": "Er du sikker på, at du vil gendanne denne version?",
|
||||
"com_ui_agent_version_restore_error": "Kunne ikke gendanne version",
|
||||
"com_ui_agent_version_restore_success": "Version gendannet med succes",
|
||||
"com_ui_agent_version_title": "Version {{versionNumber}}",
|
||||
"com_ui_agent_version_unknown_date": "Ukendt dato",
|
||||
"com_ui_agents": "Agenter",
|
||||
"com_ui_agents_allow_create": "Tillad oprettelse af agenter",
|
||||
"com_ui_agents_allow_share_global": "Tillad deling af agenter til alle brugere",
|
||||
@@ -528,6 +612,7 @@
|
||||
"com_ui_confirm_change": "Bekræft ændring",
|
||||
"com_ui_context": "Kontekst",
|
||||
"com_ui_continue": "Fortsæt",
|
||||
"com_ui_controls": "Kontrolelementer",
|
||||
"com_ui_convo_delete_error": "Kunne ikke slette samtalen",
|
||||
"com_ui_copied": "Kopieret!",
|
||||
"com_ui_copied_to_clipboard": "Kopieret til udklipsholder",
|
||||
@@ -617,8 +702,23 @@
|
||||
"com_ui_fork_default": "Brug standard forgreningsmulighed",
|
||||
"com_ui_fork_error": "Der opstod en fejl under forgreningen af samtalen",
|
||||
"com_ui_fork_from_message": "Vælg en forgreningsmulighed",
|
||||
"com_ui_fork_info_1": "Brug denne indstilling til at forkaste meddelelser med den ønskede adfærd.",
|
||||
"com_ui_fork_info_2": "\"Forgrening\" betyder, at man opretter en ny samtale, der starter/slutter med specifikke beskeder i den aktuelle samtale og opretter en kopi i henhold til de valgte indstillinger.",
|
||||
"com_ui_fork_info_3": "\"Målbeskeden\" henviser enten til den besked, som denne popup blev åbnet fra, eller, hvis du markerer \"{{0}}\", den seneste besked i samtalen.",
|
||||
"com_ui_fork_info_branches": "Denne indstilling forgrener de synlige beskeder sammen med relaterede grene",
|
||||
"com_ui_fork_info_button_label": "Se oplysninger om forgrening af samtaler",
|
||||
"com_ui_fork_info_remember": "Marker dette for at huske de indstillinger, du vælger, til fremtidig brug, hvilket gør det hurtigere at forgrene samtaler som ønsket.",
|
||||
"com_ui_fork_info_start": "Hvis det er markeret, vil forgrening begynde fra denne besked til den seneste besked i samtalen i henhold til den adfærd, der er valgt ovenfor.",
|
||||
"com_ui_fork_info_target": "Denne indstilling forgrener alle beskeder, der fører op til målbeskeden, inklusive dens naboer",
|
||||
"com_ui_fork_info_visible": "Denne indstilling deler kun de synlige meddelelser; med andre ord den direkte vej til målmeddelelsen uden nogen forgrening.",
|
||||
"com_ui_fork_more_details_about": "Se yderligere oplysninger og detaljer om \"{{0}}\" forgreningsmulighed",
|
||||
"com_ui_fork_more_info_options": "Se detaljeret forklaring af alle forgreningslindstillinger og deres adfærd",
|
||||
"com_ui_fork_processing": "Forgrener samtale...",
|
||||
"com_ui_fork_remember": "Husk ",
|
||||
"com_ui_fork_remember_checked": "Dit valg vil blive husket efter brug. Du kan til enhver tid ændre det i indstillingerne.",
|
||||
"com_ui_fork_split_target": "Start forgrening her",
|
||||
"com_ui_fork_split_target_setting": "Start forgrening fra målbesked som standard",
|
||||
"com_ui_fork_success": "Samtalen er forgrenet",
|
||||
"com_ui_fork_visible": "Kun synlige beskeder",
|
||||
"com_ui_generate_backup": "Generer backup-koder",
|
||||
"com_ui_generate_qrcode": "Generer QR-kode",
|
||||
@@ -643,7 +743,6 @@
|
||||
"com_ui_import_conversation_info": "Importer samtaler fra en JSON-fil",
|
||||
"com_ui_import_conversation_success": "Samtaler importeret med succes",
|
||||
"com_ui_include_shadcnui": "Inkluder instruktioner til shadcn/ui-komponenter",
|
||||
"com_ui_include_shadcnui_agent": "Inkluder instruktioner til shadcn/ui",
|
||||
"com_ui_input": "Input",
|
||||
"com_ui_instructions": "Instruktioner",
|
||||
"com_ui_late_night": "Glædelig sen aften",
|
||||
@@ -818,11 +917,30 @@
|
||||
"com_ui_version_var": "Version {{0}}",
|
||||
"com_ui_versions": "Versioner",
|
||||
"com_ui_view_source": "Se kilde-chat",
|
||||
"com_ui_web_search": "Websøgning",
|
||||
"com_ui_web_search_api_subtitle": "Søg på nettet efter opdateret information",
|
||||
"com_ui_web_search_cohere_key": "Indtast Cohere API-nøgle",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (valgfri)",
|
||||
"com_ui_web_search_jina_key": "Indtast Jina API-nøgle",
|
||||
"com_ui_web_search_processing": "Behandler resultater",
|
||||
"com_ui_web_search_provider": "Søgeudbyder",
|
||||
"com_ui_web_search_provider_serper": "Serper API",
|
||||
"com_ui_web_search_provider_serper_key": "Få din Serper API-nøgle",
|
||||
"com_ui_web_search_reading": "Læser resultater",
|
||||
"com_ui_web_search_reranker": "Genanker",
|
||||
"com_ui_web_search_reranker_cohere": "Cohere",
|
||||
"com_ui_web_search_reranker_cohere_key": "Få din Cohere API-nøgle",
|
||||
"com_ui_web_search_reranker_jina": "Jina AI",
|
||||
"com_ui_web_search_reranker_jina_key": "Få din Jina API-nøgle",
|
||||
"com_ui_web_search_scraper": "Skraber",
|
||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "Få din Firecrawl API-nøgle",
|
||||
"com_ui_web_searching": "Søger på nettet",
|
||||
"com_ui_web_searching_again": "Søger på nettet igen",
|
||||
"com_ui_weekend_morning": "God weekend",
|
||||
"com_ui_write": "Skriver",
|
||||
"com_ui_x_selected": "{{0}} udvalgt",
|
||||
"com_ui_yes": "Ja",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Du",
|
||||
"com_warning_resubmit_unsupported": "Genindsendelse af AI-beskeden understøttes ikke for dette slutpunkt."
|
||||
"com_user_message": "Du"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user