Compare commits
33 Commits
feat/compo
...
fix/file-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb27dbaff | ||
|
|
1b2f1ff09b | ||
|
|
0a2f40cc50 | ||
|
|
8c531b921e | ||
|
|
f228f2a91d | ||
|
|
59b57623f7 | ||
|
|
61c4736125 | ||
|
|
d844754edf | ||
|
|
6522789f5b | ||
|
|
e71c48ec3d | ||
|
|
f6868fc851 | ||
|
|
c2505d2bc9 | ||
|
|
cabc8afeac | ||
|
|
aff3cd3667 | ||
|
|
c9ee0f138a | ||
|
|
bc561840bb | ||
|
|
6e19026c48 | ||
|
|
524fc5bae4 | ||
|
|
3f62ce054f | ||
|
|
b8b1217c34 | ||
|
|
4186db3ce2 | ||
|
|
7670cd9ee5 | ||
|
|
dd35f42073 | ||
|
|
a49c509ebc | ||
|
|
970a7510bb | ||
|
|
2b0fe036a8 | ||
|
|
4685a063f5 | ||
|
|
b6ba2711f9 | ||
|
|
4e4c8d0c0e | ||
|
|
937563f645 | ||
|
|
b443254151 | ||
|
|
2524d33362 | ||
|
|
06c060b983 |
21
.github/workflows/cache-integration-tests.yml
vendored
21
.github/workflows/cache-integration-tests.yml
vendored
@@ -61,30 +61,23 @@ jobs:
|
||||
npm run build:data-schemas
|
||||
npm run build:api
|
||||
|
||||
- name: Run cache integration tests
|
||||
- name: Run all cache integration tests (Single Redis Node)
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
USE_REDIS_CLUSTER: false
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache-integration:core
|
||||
run: npm run test:cache-integration
|
||||
|
||||
- name: Run cluster integration tests
|
||||
- name: Run all cache integration tests (Redis Cluster)
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:cluster
|
||||
|
||||
- name: Run mcp integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:mcp
|
||||
USE_REDIS_CLUSTER: true
|
||||
REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache-integration
|
||||
|
||||
- name: Stop Redis Cluster
|
||||
if: always()
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
- [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
|
||||
- Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
|
||||
- Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
|
||||
- OpenRouter, Perplexity, ShuttleAI, Deepseek, Qwen, and more
|
||||
- OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen, and more
|
||||
|
||||
- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
|
||||
- Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran
|
||||
|
||||
@@ -305,11 +305,9 @@ class AnthropicClient extends BaseClient {
|
||||
}
|
||||
|
||||
async addImageURLs(message, attachments) {
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
EModelEndpoint.anthropic,
|
||||
);
|
||||
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
|
||||
endpoint: EModelEndpoint.anthropic,
|
||||
});
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -1213,6 +1213,7 @@ class BaseClient {
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent?.provider,
|
||||
endpoint: this.options.agent?.endpoint,
|
||||
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
@@ -1228,7 +1229,10 @@ class BaseClient {
|
||||
const videoResult = await encodeAndFormatVideos(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
{
|
||||
provider: this.options.agent?.provider,
|
||||
endpoint: this.options.agent?.endpoint,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.videos =
|
||||
@@ -1240,7 +1244,10 @@ class BaseClient {
|
||||
const audioResult = await encodeAndFormatAudios(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
{
|
||||
provider: this.options.agent?.provider,
|
||||
endpoint: this.options.agent?.endpoint,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.audios =
|
||||
|
||||
@@ -305,7 +305,9 @@ class GoogleClient extends BaseClient {
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
EModelEndpoint.google,
|
||||
{
|
||||
endpoint: EModelEndpoint.google,
|
||||
},
|
||||
mode,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
|
||||
@@ -354,11 +354,9 @@ class OpenAIClient extends BaseClient {
|
||||
* @returns {Promise<MongoFile[]>}
|
||||
*/
|
||||
async addImageURLs(message, attachments) {
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.endpoint,
|
||||
);
|
||||
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
|
||||
endpoint: this.options.endpoint,
|
||||
});
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -78,11 +78,11 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||
return tool(
|
||||
async ({ query }) => {
|
||||
if (files.length === 0) {
|
||||
return 'No files to search. Instruct the user to add files for the search.';
|
||||
return ['No files to search. Instruct the user to add files for the search.', {}];
|
||||
}
|
||||
const jwtToken = generateShortLivedToken(userId);
|
||||
if (!jwtToken) {
|
||||
return 'There was an error authenticating the file search request.';
|
||||
return ['There was an error authenticating the file search request.', {}];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +122,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||
const validResults = results.filter((result) => result !== null);
|
||||
|
||||
if (validResults.length === 0) {
|
||||
return 'No results found or errors occurred while searching the files.';
|
||||
return ['No results found or errors occurred while searching the files.', {}];
|
||||
}
|
||||
|
||||
const formattedResults = validResults
|
||||
|
||||
@@ -5,6 +5,7 @@ const traverse = require('traverse');
|
||||
const SPLAT_SYMBOL = Symbol.for('splat');
|
||||
const MESSAGE_SYMBOL = Symbol.for('message');
|
||||
const CONSOLE_JSON_STRING_LENGTH = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH) || 255;
|
||||
const DEBUG_MESSAGE_LENGTH = parseInt(process.env.DEBUG_MESSAGE_LENGTH) || 150;
|
||||
|
||||
const sensitiveKeys = [
|
||||
/^(sk-)[^\s]+/, // OpenAI API key pattern
|
||||
@@ -118,7 +119,7 @@ const debugTraverse = winston.format.printf(({ level, message, timestamp, ...met
|
||||
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
|
||||
}
|
||||
|
||||
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), 150)}`;
|
||||
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), DEBUG_MESSAGE_LENGTH)}`;
|
||||
try {
|
||||
if (level !== 'debug') {
|
||||
return msg;
|
||||
|
||||
@@ -43,15 +43,15 @@
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@googleapis/youtube": "^20.0.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@langchain/core": "^0.3.79",
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^3.0.5",
|
||||
"@librechat/agents": "^3.0.17",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"@modelcontextprotocol/sdk": "^1.21.0",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.12.1",
|
||||
@@ -76,7 +76,7 @@
|
||||
"handlebars": "^4.7.7",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"keyv": "^5.3.2",
|
||||
@@ -117,7 +117,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"jest": "^30.2.0",
|
||||
"mongodb-memory-server": "^10.1.4",
|
||||
"nodemon": "^3.0.3",
|
||||
"supertest": "^7.1.0"
|
||||
|
||||
@@ -376,6 +376,8 @@ function disposeClient(client) {
|
||||
client.options = null;
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
} finally {
|
||||
logger.debug('[disposeClient] Client disposed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { nanoid } = require('nanoid');
|
||||
const { sendEvent } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
|
||||
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
EnvVar,
|
||||
Providers,
|
||||
@@ -27,6 +27,13 @@ class ModelEndHandler {
|
||||
this.collectedUsage = collectedUsage;
|
||||
}
|
||||
|
||||
finalize(errorMessage) {
|
||||
if (!errorMessage) {
|
||||
return;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} event
|
||||
* @param {ModelEndData | undefined} data
|
||||
@@ -40,18 +47,44 @@ class ModelEndHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let errorMessage;
|
||||
try {
|
||||
const agentContext = graph.getAgentContext(metadata);
|
||||
if (
|
||||
agentContext.provider === Providers.GOOGLE ||
|
||||
agentContext.clientOptions?.disableStreaming
|
||||
) {
|
||||
handleToolCalls(data?.output?.tool_calls, metadata, graph);
|
||||
const isGoogle = agentContext.provider === Providers.GOOGLE;
|
||||
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
|
||||
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
|
||||
const info = { ...data.output.additional_kwargs };
|
||||
errorMessage = JSON.stringify({
|
||||
type: ErrorTypes.REFUSAL,
|
||||
info,
|
||||
});
|
||||
logger.debug(`[ModelEndHandler] Model refused to respond`, {
|
||||
...info,
|
||||
userId: metadata.user_id,
|
||||
messageId: metadata.run_id,
|
||||
conversationId: metadata.thread_id,
|
||||
});
|
||||
}
|
||||
|
||||
const toolCalls = data?.output?.tool_calls;
|
||||
let hasUnprocessedToolCalls = false;
|
||||
if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
|
||||
try {
|
||||
hasUnprocessedToolCalls = toolCalls.some(
|
||||
(tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
|
||||
);
|
||||
} catch {
|
||||
hasUnprocessedToolCalls = false;
|
||||
}
|
||||
}
|
||||
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
|
||||
handleToolCalls(toolCalls, metadata, graph);
|
||||
}
|
||||
|
||||
const usage = data?.output?.usage_metadata;
|
||||
if (!usage) {
|
||||
return;
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
|
||||
if (modelName) {
|
||||
@@ -59,12 +92,11 @@ class ModelEndHandler {
|
||||
}
|
||||
|
||||
this.collectedUsage.push(usage);
|
||||
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
|
||||
if (!streamingDisabled) {
|
||||
return;
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
if (!data.output.content) {
|
||||
return;
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
const stepKey = graph.getStepKey(metadata);
|
||||
const message_id = getMessageId(stepKey, graph) ?? '';
|
||||
@@ -94,6 +126,7 @@ class ModelEndHandler {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling model end event:', error);
|
||||
return this.finalize(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
memoryInstructions,
|
||||
getTransactionsConfig,
|
||||
createMemoryProcessor,
|
||||
filterMalformedContentParts,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Callback,
|
||||
@@ -210,7 +211,10 @@ class AgentClient extends BaseClient {
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
{
|
||||
provider: this.options.agent.provider,
|
||||
endpoint: this.options.endpoint,
|
||||
},
|
||||
VisionModes.agents,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
@@ -341,7 +345,7 @@ class AgentClient extends BaseClient {
|
||||
|
||||
if (mcpServers.length > 0) {
|
||||
try {
|
||||
const mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers);
|
||||
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
|
||||
if (mcpInstructions) {
|
||||
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
|
||||
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
|
||||
@@ -608,7 +612,7 @@ class AgentClient extends BaseClient {
|
||||
userMCPAuthMap: opts.userMCPAuthMap,
|
||||
abortController: opts.abortController,
|
||||
});
|
||||
return this.contentParts;
|
||||
return filterMalformedContentParts(this.contentParts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -761,12 +765,14 @@ class AgentClient extends BaseClient {
|
||||
let run;
|
||||
/** @type {Promise<(TAttachment | null)[] | undefined>} */
|
||||
let memoryPromise;
|
||||
const appConfig = this.options.req.config;
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
try {
|
||||
if (!abortController) {
|
||||
abortController = new AbortController();
|
||||
}
|
||||
|
||||
const appConfig = this.options.req.config;
|
||||
/** @type {AppConfig['endpoints']['agents']} */
|
||||
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
|
||||
|
||||
@@ -896,31 +902,7 @@ class AgentClient extends BaseClient {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
|
||||
err,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||
err,
|
||||
@@ -935,6 +917,24 @@ class AgentClient extends BaseClient {
|
||||
[ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
this.artifactPromises.push(...attachments);
|
||||
}
|
||||
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
}));
|
||||
|
||||
// Mock getMCPManager
|
||||
const mockFormatInstructions = jest.fn();
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(() => ({
|
||||
formatInstructionsForContext: mockFormatInstructions,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('AgentClient - titleConvo', () => {
|
||||
let client;
|
||||
let mockRun;
|
||||
@@ -981,7 +989,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic that handles GPT-5+ models
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1001,7 +1009,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
const paramName =
|
||||
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
@@ -1026,7 +1034,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1047,7 +1055,7 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1060,6 +1068,9 @@ describe('AgentClient - titleConvo', () => {
|
||||
|
||||
it('should handle various GPT-5+ model formats', () => {
|
||||
const testCases = [
|
||||
{ model: 'gpt-5.1', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-codex', shouldTransform: true },
|
||||
{ model: 'gpt-5', shouldTransform: true },
|
||||
{ model: 'gpt-5-turbo', shouldTransform: true },
|
||||
{ model: 'gpt-6', shouldTransform: true },
|
||||
@@ -1079,7 +1090,10 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (
|
||||
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
|
||||
clientOptions.maxTokens != null
|
||||
) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1097,6 +1111,9 @@ describe('AgentClient - titleConvo', () => {
|
||||
|
||||
it('should not swap max token param for older models when using useResponsesApi', () => {
|
||||
const testCases = [
|
||||
{ model: 'gpt-5.1', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
|
||||
{ model: 'gpt-5.1-codex', shouldTransform: true },
|
||||
{ model: 'gpt-5', shouldTransform: true },
|
||||
{ model: 'gpt-5-turbo', shouldTransform: true },
|
||||
{ model: 'gpt-6', shouldTransform: true },
|
||||
@@ -1116,7 +1133,10 @@ describe('AgentClient - titleConvo', () => {
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (
|
||||
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
|
||||
clientOptions.maxTokens != null
|
||||
) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
const paramName =
|
||||
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
@@ -1149,7 +1169,10 @@ describe('AgentClient - titleConvo', () => {
|
||||
};
|
||||
|
||||
// Simulate the getOptions logic
|
||||
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||
if (
|
||||
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
|
||||
clientOptions.maxTokens != null
|
||||
) {
|
||||
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
|
||||
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
|
||||
delete clientOptions.maxTokens;
|
||||
@@ -1168,6 +1191,200 @@ describe('AgentClient - titleConvo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessages with MCP server instructions', () => {
|
||||
let client;
|
||||
let mockReq;
|
||||
let mockRes;
|
||||
let mockAgent;
|
||||
let mockOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset the mock to default behavior
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# MCP Server Instructions\n\nTest MCP instructions here',
|
||||
);
|
||||
|
||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
|
||||
// Create mock MCP tools with the delimiter pattern
|
||||
const mockMCPTool1 = new DynamicStructuredTool({
|
||||
name: `tool1${Constants.mcp_delimiter}server1`,
|
||||
description: 'Test MCP tool 1',
|
||||
schema: {},
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
const mockMCPTool2 = new DynamicStructuredTool({
|
||||
name: `tool2${Constants.mcp_delimiter}server2`,
|
||||
description: 'Test MCP tool 2',
|
||||
schema: {},
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
mockAgent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
instructions: 'Base agent instructions',
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
tools: [mockMCPTool1, mockMCPTool2],
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
},
|
||||
body: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
|
||||
mockRes = {};
|
||||
|
||||
mockOptions = {
|
||||
req: mockReq,
|
||||
res: mockRes,
|
||||
agent: mockAgent,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
};
|
||||
|
||||
client = new AgentClient(mockOptions);
|
||||
client.conversationId = 'convo-123';
|
||||
client.responseMessageId = 'response-123';
|
||||
client.shouldSummarize = false;
|
||||
client.maxContextTokens = 4096;
|
||||
});
|
||||
|
||||
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
|
||||
// Set specific return value for this test
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# MCP Server Instructions\n\nUse these tools carefully',
|
||||
);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify formatInstructionsForContext was called with correct server names
|
||||
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
|
||||
|
||||
// Verify the instructions do NOT contain [object Promise]
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
|
||||
// Verify the instructions DO contain the MCP instructions
|
||||
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
|
||||
expect(client.options.agent.instructions).toContain('Use these tools carefully');
|
||||
|
||||
// Verify the base instructions are also included
|
||||
expect(client.options.agent.instructions).toContain('Base instructions');
|
||||
});
|
||||
|
||||
it('should handle MCP instructions with ephemeral agent', async () => {
|
||||
// Set specific return value for this test
|
||||
mockFormatInstructions.mockResolvedValue(
|
||||
'# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
|
||||
);
|
||||
|
||||
// Set up ephemeral agent with MCP servers
|
||||
mockReq.body.ephemeralAgent = {
|
||||
mcp: ['ephemeral-server1', 'ephemeral-server2'],
|
||||
};
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Test ephemeral',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Ephemeral instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify formatInstructionsForContext was called with ephemeral server names
|
||||
expect(mockFormatInstructions).toHaveBeenCalledWith([
|
||||
'ephemeral-server1',
|
||||
'ephemeral-server2',
|
||||
]);
|
||||
|
||||
// Verify no [object Promise] in instructions
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
|
||||
// Verify ephemeral MCP instructions are included
|
||||
expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
|
||||
expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
|
||||
});
|
||||
|
||||
it('should handle empty MCP instructions gracefully', async () => {
|
||||
// Set empty return value for this test
|
||||
mockFormatInstructions.mockResolvedValue('');
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions only',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Verify the instructions still work without MCP content
|
||||
expect(client.options.agent.instructions).toBe('Base instructions only');
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
});
|
||||
|
||||
it('should handle MCP instructions error gracefully', async () => {
|
||||
// Set error return for this test
|
||||
mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
|
||||
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'msg-1',
|
||||
parentMessageId: null,
|
||||
sender: 'User',
|
||||
text: 'Hello',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
await client.buildMessages(messages, null, {
|
||||
instructions: 'Base instructions',
|
||||
additional_instructions: null,
|
||||
});
|
||||
|
||||
// Should still have base instructions without MCP content
|
||||
expect(client.options.agent.instructions).toContain('Base instructions');
|
||||
expect(client.options.agent.instructions).not.toContain('[object Promise]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMemory method', () => {
|
||||
let client;
|
||||
let mockReq;
|
||||
|
||||
@@ -185,8 +185,8 @@ process.on('uncaughtException', (err) => {
|
||||
logger.error('There was an uncaught error:', err);
|
||||
}
|
||||
|
||||
if (err.message.includes('abort')) {
|
||||
logger.warn('There was an uncatchable AbortController error.');
|
||||
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
|
||||
logger.warn('There was an uncatchable abort error.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ describe('MCP Routes', () => {
|
||||
it('should handle OAuth callback successfully', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
@@ -382,6 +383,7 @@ describe('MCP Routes', () => {
|
||||
it('should handle system-level OAuth completion', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
@@ -417,6 +419,7 @@ describe('MCP Routes', () => {
|
||||
it('should handle reconnection failure after OAuth', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
@@ -498,6 +501,108 @@ describe('MCP Routes', () => {
|
||||
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
|
||||
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use original flow state credentials when storing tokens', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(),
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const clientInfo = {
|
||||
client_id: 'client123',
|
||||
client_secret: 'client_secret',
|
||||
};
|
||||
const flowState = {
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
|
||||
clientInfo: clientInfo,
|
||||
codeVerifier: 'test-verifier',
|
||||
status: 'PENDING',
|
||||
};
|
||||
const mockTokens = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
};
|
||||
|
||||
// First call checks idempotency (status PENDING = not completed)
|
||||
// Second call retrieves flow state for processing
|
||||
mockFlowManager.getFlowState
|
||||
.mockResolvedValueOnce({ status: 'PENDING' })
|
||||
.mockResolvedValueOnce(flowState);
|
||||
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockUserConnection = {
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
|
||||
clearReconnection: jest.fn(),
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
||||
|
||||
// Verify storeTokens was called with ORIGINAL flow state credentials
|
||||
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'test-user-id',
|
||||
serverName: 'test-server',
|
||||
tokens: mockTokens,
|
||||
clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
|
||||
metadata: flowState.metadata,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent duplicate token exchange with idempotency check', async () => {
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(),
|
||||
};
|
||||
|
||||
// Flow is already completed
|
||||
mockFlowManager.getFlowState.mockResolvedValue({
|
||||
status: 'COMPLETED',
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
});
|
||||
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue({
|
||||
status: 'COMPLETED',
|
||||
serverName: 'test-server',
|
||||
userId: 'test-user-id',
|
||||
});
|
||||
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
||||
|
||||
// Verify completeOAuthFlow was NOT called (prevented duplicate)
|
||||
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
|
||||
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /oauth/tokens/:flowId', () => {
|
||||
@@ -1242,7 +1347,9 @@ describe('MCP Routes', () => {
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
||||
completeFlow: jest.fn(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ const {
|
||||
PermissionTypes,
|
||||
actionDelimiter,
|
||||
removeNullishValues,
|
||||
validateActionDomain,
|
||||
validateAndParseOpenAPISpec,
|
||||
} = require('librechat-data-provider');
|
||||
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
|
||||
const { findAccessibleResources } = require('~/server/services/PermissionService');
|
||||
@@ -83,6 +85,32 @@ router.post(
|
||||
|
||||
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||
const appConfig = req.config;
|
||||
|
||||
// SECURITY: Validate the OpenAPI spec and extract the server URL
|
||||
if (metadata.raw_spec) {
|
||||
const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
|
||||
if (!validationResult.status || !validationResult.serverUrl) {
|
||||
return res.status(400).json({
|
||||
message: validationResult.message || 'Invalid OpenAPI specification',
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY: Validate the client-provided domain matches the spec's server URL domain
|
||||
// This prevents SSRF attacks where an attacker provides a whitelisted domain
|
||||
// but uses a different (potentially internal) URL in the raw_spec
|
||||
const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
|
||||
userId: req.user.id,
|
||||
agent_id,
|
||||
});
|
||||
return res.status(400).json({
|
||||
message:
|
||||
'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isDomainAllowed = await isActionDomainAllowed(
|
||||
metadata.domain,
|
||||
appConfig?.actions?.allowedDomains,
|
||||
|
||||
@@ -10,8 +10,8 @@ const {
|
||||
ResourceType,
|
||||
EModelEndpoint,
|
||||
PermissionBits,
|
||||
isAgentsEndpoint,
|
||||
checkOpenAIStorage,
|
||||
isAssistantsEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
filterFile,
|
||||
@@ -376,11 +376,11 @@ router.post('/', async (req, res) => {
|
||||
metadata.temp_file_id = metadata.file_id;
|
||||
metadata.file_id = req.file_id;
|
||||
|
||||
if (isAgentsEndpoint(metadata.endpoint)) {
|
||||
return await processAgentFileUpload({ req, res, metadata });
|
||||
if (isAssistantsEndpoint(metadata.endpoint)) {
|
||||
return await processFileUpload({ req, res, metadata });
|
||||
}
|
||||
|
||||
await processFileUpload({ req, res, metadata });
|
||||
return await processAgentFileUpload({ req, res, metadata });
|
||||
} catch (error) {
|
||||
let message = 'Error processing file';
|
||||
logger.error('[/files] Error processing file:', error);
|
||||
|
||||
@@ -3,7 +3,11 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { sanitizeFilename } = require('@librechat/api');
|
||||
const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider');
|
||||
const {
|
||||
mergeFileConfig,
|
||||
getEndpointFileConfig,
|
||||
fileConfig: defaultFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
@@ -53,12 +57,14 @@ const createFileFilter = (customFileConfig) => {
|
||||
}
|
||||
|
||||
const endpoint = req.body.endpoint;
|
||||
const supportedTypes =
|
||||
customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ??
|
||||
customFileConfig?.endpoints?.default.supportedMimeTypes ??
|
||||
defaultFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes;
|
||||
const endpointType = req.body.endpointType;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig: customFileConfig,
|
||||
endpoint,
|
||||
endpointType,
|
||||
});
|
||||
|
||||
if (!defaultFileConfig.checkType(file.mimetype, supportedTypes)) {
|
||||
if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
|
||||
return cb(new Error('Unsupported file type: ' + file.mimetype), false);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,16 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
hasCodeVerifier: !!flowState.codeVerifier,
|
||||
});
|
||||
|
||||
/** Check if this flow has already been completed (idempotency protection) */
|
||||
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||
if (currentFlowState?.status === 'COMPLETED') {
|
||||
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
|
||||
flowId,
|
||||
serverName,
|
||||
});
|
||||
return res.redirect(`/oauth/success?serverName=${encodeURIComponent(serverName)}`);
|
||||
}
|
||||
|
||||
logger.debug('[MCP OAuth] Completing OAuth flow');
|
||||
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const { unescapeLaTeX } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
@@ -134,17 +135,32 @@ router.post('/artifact/:messageId', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Artifact index out of bounds' });
|
||||
}
|
||||
|
||||
// Unescape LaTeX preprocessing done by the frontend
|
||||
// The frontend escapes $ signs for display, but the database has unescaped versions
|
||||
const unescapedOriginal = unescapeLaTeX(original);
|
||||
const unescapedUpdated = unescapeLaTeX(updated);
|
||||
|
||||
const targetArtifact = artifacts[index];
|
||||
let updatedText = null;
|
||||
|
||||
if (targetArtifact.source === 'content') {
|
||||
const part = message.content[targetArtifact.partIndex];
|
||||
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
|
||||
updatedText = replaceArtifactContent(
|
||||
part.text,
|
||||
targetArtifact,
|
||||
unescapedOriginal,
|
||||
unescapedUpdated,
|
||||
);
|
||||
if (updatedText) {
|
||||
part.text = updatedText;
|
||||
}
|
||||
} else {
|
||||
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
|
||||
updatedText = replaceArtifactContent(
|
||||
message.text,
|
||||
targetArtifact,
|
||||
unescapedOriginal,
|
||||
unescapedUpdated,
|
||||
);
|
||||
if (updatedText) {
|
||||
message.text = updatedText;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ async function getEndpointsConfig(req) {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const checkCapability = async (req, capability) => {
|
||||
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
|
||||
const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint);
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
const capabilities =
|
||||
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const { isUserProvided, normalizeEndpointName } = require('@librechat/api');
|
||||
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
|
||||
const { isUserProvided } = require('@librechat/api');
|
||||
const {
|
||||
EModelEndpoint,
|
||||
extractEnvVariable,
|
||||
normalizeEndpointName,
|
||||
} = require('librechat-data-provider');
|
||||
const { fetchModels } = require('~/server/services/ModelService');
|
||||
const { getAppConfig } = require('./app');
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ const {
|
||||
primeResources,
|
||||
getModelMaxTokens,
|
||||
extractLibreChatParams,
|
||||
filterFilesByEndpointConfig,
|
||||
optionalChainWithEmptyCheck,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
ErrorTypes,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
paramEndpoints,
|
||||
isAgentsEndpoint,
|
||||
replaceSpecialVars,
|
||||
providerEndpointMap,
|
||||
@@ -71,6 +73,9 @@ const initializeAgent = async ({
|
||||
|
||||
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions);
|
||||
|
||||
const provider = agent.provider;
|
||||
agent.endpoint = provider;
|
||||
|
||||
if (isInitialAgent && conversationId != null && resendFiles) {
|
||||
const fileIds = (await getConvoFiles(conversationId)) ?? [];
|
||||
/** @type {Set<EToolResources>} */
|
||||
@@ -88,6 +93,19 @@ const initializeAgent = async ({
|
||||
currentFiles = await processFiles(requestFiles);
|
||||
}
|
||||
|
||||
if (currentFiles && currentFiles.length) {
|
||||
let endpointType;
|
||||
if (!paramEndpoints.has(agent.endpoint)) {
|
||||
endpointType = EModelEndpoint.custom;
|
||||
}
|
||||
|
||||
currentFiles = filterFilesByEndpointConfig(req, {
|
||||
files: currentFiles,
|
||||
endpoint: agent.endpoint,
|
||||
endpointType,
|
||||
});
|
||||
}
|
||||
|
||||
const { attachments, tool_resources } = await primeResources({
|
||||
req,
|
||||
getFiles,
|
||||
@@ -98,7 +116,6 @@ const initializeAgent = async ({
|
||||
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
||||
});
|
||||
|
||||
const provider = agent.provider;
|
||||
const {
|
||||
tools: structuredTools,
|
||||
toolContextMap,
|
||||
@@ -113,7 +130,6 @@ const initializeAgent = async ({
|
||||
tool_resources,
|
||||
})) ?? {};
|
||||
|
||||
agent.endpoint = provider;
|
||||
const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
|
||||
if (overrideProvider !== agent.provider) {
|
||||
agent.provider = overrideProvider;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
const axios = require('axios');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logAxiosError, validateImage } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
VisionModes,
|
||||
ImageDetail,
|
||||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
|
||||
@@ -84,11 +86,15 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
|
||||
* Encodes and formats the given files.
|
||||
* @param {ServerRequest} req - The request object.
|
||||
* @param {Array<MongoFile>} files - The array of files to encode and format.
|
||||
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
|
||||
* @param {object} params - Object containing provider/endpoint information
|
||||
* @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
|
||||
* @param {string} [params.endpoint] - Optional: The endpoint for the image
|
||||
* @param {string} [mode] - Optional: The endpoint mode for the image.
|
||||
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
|
||||
*/
|
||||
async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
async function encodeAndFormat(req, files, params, mode) {
|
||||
const { provider, endpoint } = params;
|
||||
const effectiveEndpoint = endpoint ?? provider;
|
||||
const promises = [];
|
||||
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
|
||||
const encodingMethods = {};
|
||||
@@ -134,7 +140,7 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
} catch (error) {
|
||||
logger.error('Error processing image from blob storage:', error);
|
||||
}
|
||||
} else if (source !== FileSources.local && base64Only.has(endpoint)) {
|
||||
} else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) {
|
||||
const [_file, imageURL] = await preparePayload(req, file);
|
||||
promises.push([_file, await fetchImageToBase64(imageURL)]);
|
||||
continue;
|
||||
@@ -148,6 +154,17 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
const formattedImages = await Promise.all(promises);
|
||||
promises.length = 0;
|
||||
|
||||
/** Extract configured file size limit from fileConfig for this endpoint */
|
||||
let configuredFileSizeLimit;
|
||||
if (req.config?.fileConfig) {
|
||||
const fileConfig = mergeFileConfig(req.config.fileConfig);
|
||||
const endpointConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint: effectiveEndpoint,
|
||||
});
|
||||
configuredFileSizeLimit = endpointConfig?.fileSizeLimit;
|
||||
}
|
||||
|
||||
for (const [file, imageContent] of formattedImages) {
|
||||
const fileMetadata = {
|
||||
type: file.type,
|
||||
@@ -168,6 +185,26 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** Validate image buffer against size limits */
|
||||
if (file.height && file.width) {
|
||||
const imageBuffer = imageContent.startsWith('http')
|
||||
? null
|
||||
: Buffer.from(imageContent, 'base64');
|
||||
|
||||
if (imageBuffer) {
|
||||
const validation = await validateImage(
|
||||
imageBuffer,
|
||||
imageBuffer.length,
|
||||
effectiveEndpoint,
|
||||
configuredFileSizeLimit,
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagePart = {
|
||||
type: ContentTypes.IMAGE_URL,
|
||||
image_url: {
|
||||
@@ -184,15 +221,19 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) {
|
||||
if (
|
||||
effectiveEndpoint &&
|
||||
effectiveEndpoint === EModelEndpoint.google &&
|
||||
mode === VisionModes.generative
|
||||
) {
|
||||
delete imagePart.image_url;
|
||||
imagePart.inlineData = {
|
||||
mimeType: file.type,
|
||||
data: imageContent,
|
||||
};
|
||||
} else if (endpoint && endpoint === EModelEndpoint.google) {
|
||||
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) {
|
||||
imagePart.image_url = imagePart.image_url.url;
|
||||
} else if (endpoint && endpoint === EModelEndpoint.anthropic) {
|
||||
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) {
|
||||
imagePart.type = 'image';
|
||||
imagePart.source = {
|
||||
type: 'base64',
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
checkOpenAIStorage,
|
||||
removeNullishValues,
|
||||
isAssistantsEndpoint,
|
||||
getEndpointFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
@@ -994,7 +995,7 @@ async function saveBase64Image(
|
||||
*/
|
||||
function filterFile({ req, image, isAvatar }) {
|
||||
const { file } = req;
|
||||
const { endpoint, file_id, width, height } = req.body;
|
||||
const { endpoint, endpointType, file_id, width, height } = req.body;
|
||||
|
||||
if (!file_id && !isAvatar) {
|
||||
throw new Error('No file_id provided');
|
||||
@@ -1016,9 +1017,13 @@ function filterFile({ req, image, isAvatar }) {
|
||||
const appConfig = req.config;
|
||||
const fileConfig = mergeFileConfig(appConfig.fileConfig);
|
||||
|
||||
const { fileSizeLimit: sizeLimit, supportedMimeTypes } =
|
||||
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
|
||||
const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
endpoint,
|
||||
fileConfig,
|
||||
endpointType,
|
||||
});
|
||||
const fileSizeLimit =
|
||||
isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit;
|
||||
|
||||
if (file.size > fileSizeLimit) {
|
||||
throw new Error(
|
||||
@@ -1028,7 +1033,10 @@ function filterFile({ req, image, isAvatar }) {
|
||||
);
|
||||
}
|
||||
|
||||
const isSupportedMimeType = fileConfig.checkType(file.mimetype, supportedMimeTypes);
|
||||
const isSupportedMimeType = fileConfig.checkType(
|
||||
file.mimetype,
|
||||
endpointFileConfig.supportedMimeTypes,
|
||||
);
|
||||
|
||||
if (!isSupportedMimeType) {
|
||||
throw new Error('Unsupported file type');
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
ImageVisionTool,
|
||||
openapiToFunction,
|
||||
AgentCapabilities,
|
||||
validateActionDomain,
|
||||
defaultAgentCapabilities,
|
||||
validateAndParseOpenAPISpec,
|
||||
} = require('librechat-data-provider');
|
||||
@@ -236,12 +237,26 @@ async function processRequiredActions(client, requiredActions) {
|
||||
|
||||
// Validate and parse OpenAPI spec
|
||||
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
||||
if (!validationResult.spec) {
|
||||
if (!validationResult.spec || !validationResult.serverUrl) {
|
||||
throw new Error(
|
||||
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Validate the domain from the spec matches the stored domain
|
||||
// This is defense-in-depth to prevent any stored malicious actions
|
||||
const domainValidation = validateActionDomain(
|
||||
action.metadata.domain,
|
||||
validationResult.serverUrl,
|
||||
);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
||||
userId: client.req.user.id,
|
||||
action_id: action.action_id,
|
||||
});
|
||||
continue; // Skip this action rather than failing the entire request
|
||||
}
|
||||
|
||||
// Process the OpenAPI spec
|
||||
const { requestBuilders } = openapiToFunction(validationResult.spec);
|
||||
|
||||
@@ -525,10 +540,25 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
||||
|
||||
// Validate and parse OpenAPI spec once per action set
|
||||
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
|
||||
if (!validationResult.spec) {
|
||||
if (!validationResult.spec || !validationResult.serverUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// SECURITY: Validate the domain from the spec matches the stored domain
|
||||
// This is defense-in-depth to prevent any stored malicious actions
|
||||
const domainValidation = validateActionDomain(
|
||||
action.metadata.domain,
|
||||
validationResult.serverUrl,
|
||||
);
|
||||
if (!domainValidation.isValid) {
|
||||
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
|
||||
userId: req.user.id,
|
||||
agent_id: agent.id,
|
||||
action_id: action.action_id,
|
||||
});
|
||||
continue; // Skip this action rather than failing the entire request
|
||||
}
|
||||
|
||||
const encrypted = {
|
||||
oauth_client_id: action.metadata.oauth_client_id,
|
||||
oauth_client_secret: action.metadata.oauth_client_secret,
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
@@ -135,10 +135,10 @@
|
||||
"babel-plugin-root-import": "^6.6.0",
|
||||
"babel-plugin-transform-import-meta": "^2.3.2",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"eslint-plugin-jest": "^28.11.0",
|
||||
"eslint-plugin-jest": "^29.1.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-file-loader": "^1.0.3",
|
||||
@@ -147,7 +147,7 @@
|
||||
"postcss-loader": "^7.1.0",
|
||||
"postcss-preset-env": "^8.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-jest": "^29.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
|
||||
16
client/public/assets/helicone.svg
Normal file
16
client/public/assets/helicone.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 50 KiB |
@@ -3,7 +3,7 @@ import type { TMessage } from 'librechat-data-provider';
|
||||
import { useChatContext } from './ChatContext';
|
||||
import { getLatestText } from '~/utils';
|
||||
|
||||
interface ArtifactsContextValue {
|
||||
export interface ArtifactsContextValue {
|
||||
isSubmitting: boolean;
|
||||
latestMessageId: string | null;
|
||||
latestMessageText: string;
|
||||
@@ -12,10 +12,15 @@ interface ArtifactsContextValue {
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
|
||||
|
||||
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
|
||||
interface ArtifactsProviderProps {
|
||||
children: React.ReactNode;
|
||||
value?: Partial<ArtifactsContextValue>;
|
||||
}
|
||||
|
||||
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
|
||||
const { isSubmitting, latestMessage, conversation } = useChatContext();
|
||||
|
||||
const latestMessageText = useMemo(() => {
|
||||
const chatLatestMessageText = useMemo(() => {
|
||||
return getLatestText({
|
||||
messageId: latestMessage?.messageId ?? null,
|
||||
text: latestMessage?.text ?? null,
|
||||
@@ -23,15 +28,20 @@ export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
|
||||
} as TMessage);
|
||||
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
||||
|
||||
/** Context value only created when relevant values change */
|
||||
const contextValue = useMemo<ArtifactsContextValue>(
|
||||
const defaultContextValue = useMemo<ArtifactsContextValue>(
|
||||
() => ({
|
||||
isSubmitting,
|
||||
latestMessageText,
|
||||
latestMessageText: chatLatestMessageText,
|
||||
latestMessageId: latestMessage?.messageId ?? null,
|
||||
conversationId: conversation?.conversationId ?? null,
|
||||
}),
|
||||
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
|
||||
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
|
||||
);
|
||||
|
||||
/** Context value only created when relevant values change */
|
||||
const contextValue = useMemo<ArtifactsContextValue>(
|
||||
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
|
||||
[defaultContextValue, value],
|
||||
);
|
||||
|
||||
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField } from '~/utils/endpoints';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface DragDropContextValue {
|
||||
|
||||
@@ -1,29 +1,76 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
|
||||
interface EditorContextType {
|
||||
/**
|
||||
* Mutation state context - for components that need to know about save/edit status
|
||||
* Separated from code state to prevent unnecessary re-renders
|
||||
*/
|
||||
interface MutationContextType {
|
||||
isMutating: boolean;
|
||||
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code state context - for components that need the current code content
|
||||
* Changes frequently (on every keystroke), so only subscribe if needed
|
||||
*/
|
||||
interface CodeContextType {
|
||||
currentCode?: string;
|
||||
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextType | undefined>(undefined);
|
||||
const MutationContext = createContext<MutationContextType | undefined>(undefined);
|
||||
const CodeContext = createContext<CodeContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provides editor state management for artifact code editing
|
||||
* Split into two contexts to prevent unnecessary re-renders:
|
||||
* - MutationContext: for save/edit status (changes rarely)
|
||||
* - CodeContext: for code content (changes on every keystroke)
|
||||
*/
|
||||
export function EditorProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const [currentCode, setCurrentCode] = useState<string | undefined>();
|
||||
|
||||
const mutationValue = useMemo(() => ({ isMutating, setIsMutating }), [isMutating]);
|
||||
const codeValue = useMemo(() => ({ currentCode, setCurrentCode }), [currentCode]);
|
||||
|
||||
return (
|
||||
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
|
||||
{children}
|
||||
</EditorContext.Provider>
|
||||
<MutationContext.Provider value={mutationValue}>
|
||||
<CodeContext.Provider value={codeValue}>{children}</CodeContext.Provider>
|
||||
</MutationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEditorContext() {
|
||||
const context = useContext(EditorContext);
|
||||
/**
|
||||
* Hook to access mutation state only
|
||||
* Use this when you only need to know about save/edit status
|
||||
*/
|
||||
export function useMutationState() {
|
||||
const context = useContext(MutationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useEditorContext must be used within an EditorProvider');
|
||||
throw new Error('useMutationState must be used within an EditorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access code state only
|
||||
* Use this when you need the current code content
|
||||
*/
|
||||
export function useCodeState() {
|
||||
const context = useContext(CodeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCodeState must be used within an EditorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use useMutationState() and/or useCodeState() instead
|
||||
* This hook causes components to re-render on every keystroke
|
||||
*/
|
||||
export function useEditorContext() {
|
||||
const mutation = useMutationState();
|
||||
const code = useCodeState();
|
||||
return { ...mutation, ...code };
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
|
||||
import type { Pluggable } from 'unified';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useMessageContext, useArtifactContext } from '~/Providers';
|
||||
import { logger, extractContent, isArtifactRoute } from '~/utils';
|
||||
import { artifactsState } from '~/store/artifacts';
|
||||
import { logger, extractContent } from '~/utils';
|
||||
import ArtifactButton from './ArtifactButton';
|
||||
|
||||
export const artifactPlugin: Pluggable = () => {
|
||||
@@ -88,7 +88,7 @@ export function Artifact({
|
||||
lastUpdateTime: now,
|
||||
};
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
if (!isArtifactRoute(location.pathname)) {
|
||||
return setArtifact(currentArtifact);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import type { Artifact } from '~/common';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { getFileType, logger } from '~/utils';
|
||||
import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -13,8 +13,9 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
const location = useLocation();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
|
||||
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const isSelected = artifact?.id === currentArtifactId;
|
||||
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
|
||||
|
||||
const debouncedSetVisibleRef = useRef(
|
||||
@@ -36,7 +37,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
if (!isArtifactRoute(location.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,35 +55,52 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
|
||||
return (
|
||||
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
{(() => {
|
||||
const handleClick = () => {
|
||||
if (isSelected) {
|
||||
resetCurrentArtifactId();
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resetCurrentArtifactId();
|
||||
setVisible(true);
|
||||
|
||||
if (artifacts?.[artifact.id] == null) {
|
||||
setArtifacts(visibleArtifacts);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentArtifactId(artifact.id);
|
||||
}, 15);
|
||||
}}
|
||||
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
|
||||
>
|
||||
<div className="w-fit bg-surface-tertiary p-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FilePreview fileType={fileType} className="relative" />
|
||||
<div className="overflow-hidden text-left">
|
||||
<div className="truncate font-medium">{artifact.title}</div>
|
||||
<div className="truncate text-text-secondary">
|
||||
{localize('com_ui_artifact_click')}
|
||||
};
|
||||
|
||||
const buttonClass = cn(
|
||||
'relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98]',
|
||||
{
|
||||
'border-border-medium bg-surface-hover shadow-lg': isSelected,
|
||||
'border-border-light bg-surface-tertiary shadow-sm': !isSelected,
|
||||
},
|
||||
);
|
||||
|
||||
const actionLabel = isSelected
|
||||
? localize('com_ui_click_to_close')
|
||||
: localize('com_ui_artifact_click');
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleClick} className={buttonClass}>
|
||||
<div className="w-fit p-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FilePreview fileType={fileType} className="relative" />
|
||||
<div className="overflow-hidden text-left">
|
||||
<div className="truncate font-medium">{artifact.title}</div>
|
||||
<div className="truncate text-text-secondary">{actionLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { KeyBinding } from '@codemirror/view';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import {
|
||||
useSandpack,
|
||||
SandpackCodeEditor,
|
||||
@@ -10,116 +12,143 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { ArtifactFiles, Artifact } from '~/common';
|
||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
||||
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
|
||||
const createDebouncedMutation = (
|
||||
callback: (params: {
|
||||
index: number;
|
||||
messageId: string;
|
||||
original: string;
|
||||
updated: string;
|
||||
}) => void,
|
||||
) => debounce(callback, 500);
|
||||
|
||||
const CodeEditor = ({
|
||||
fileKey,
|
||||
readOnly,
|
||||
artifact,
|
||||
editorRef,
|
||||
}: {
|
||||
fileKey: string;
|
||||
readOnly?: boolean;
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) => {
|
||||
const { sandpack } = useSandpack();
|
||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
|
||||
const editArtifact = useEditArtifact({
|
||||
onMutate: (vars) => {
|
||||
setIsMutating(true);
|
||||
setCurrentUpdate(vars.updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsMutating(false);
|
||||
setCurrentUpdate(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsMutating(false);
|
||||
},
|
||||
});
|
||||
|
||||
const mutationCallback = useCallback(
|
||||
(params: { index: number; messageId: string; original: string; updated: string }) => {
|
||||
editArtifact.mutate(params);
|
||||
},
|
||||
[editArtifact],
|
||||
);
|
||||
|
||||
const debouncedMutation = useMemo(
|
||||
() => createDebouncedMutation(mutationCallback),
|
||||
[mutationCallback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
if (isMutating) {
|
||||
return;
|
||||
}
|
||||
if (artifact.index == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
||||
const isNotOriginal =
|
||||
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
|
||||
const isNotRepeated =
|
||||
currentUpdate == null
|
||||
? true
|
||||
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
|
||||
|
||||
if (artifact.content && isNotOriginal && isNotRepeated) {
|
||||
setCurrentCode(currentCode);
|
||||
debouncedMutation({
|
||||
index: artifact.index,
|
||||
messageId: artifact.messageId ?? '',
|
||||
original: artifact.content,
|
||||
updated: currentCode,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedMutation.cancel();
|
||||
};
|
||||
}, [
|
||||
const CodeEditor = memo(
|
||||
({
|
||||
fileKey,
|
||||
artifact.index,
|
||||
artifact.content,
|
||||
artifact.messageId,
|
||||
readOnly,
|
||||
isMutating,
|
||||
currentUpdate,
|
||||
setIsMutating,
|
||||
sandpack.files,
|
||||
setCurrentCode,
|
||||
debouncedMutation,
|
||||
]);
|
||||
artifact,
|
||||
editorRef,
|
||||
}: {
|
||||
fileKey: string;
|
||||
readOnly?: boolean;
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) => {
|
||||
const { sandpack } = useSandpack();
|
||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||
const { isMutating, setIsMutating } = useMutationState();
|
||||
const { setCurrentCode } = useCodeState();
|
||||
const editArtifact = useEditArtifact({
|
||||
onMutate: (vars) => {
|
||||
setIsMutating(true);
|
||||
setCurrentUpdate(vars.updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsMutating(false);
|
||||
setCurrentUpdate(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsMutating(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SandpackCodeEditor
|
||||
ref={editorRef}
|
||||
showTabs={false}
|
||||
showRunButton={false}
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
readOnly={readOnly === true}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Create stable debounced mutation that doesn't depend on changing callbacks
|
||||
* Use refs to always access the latest values without recreating the debounce
|
||||
*/
|
||||
const artifactRef = useRef(artifact);
|
||||
const isMutatingRef = useRef(isMutating);
|
||||
const currentUpdateRef = useRef(currentUpdate);
|
||||
const editArtifactRef = useRef(editArtifact);
|
||||
const setCurrentCodeRef = useRef(setCurrentCode);
|
||||
|
||||
useEffect(() => {
|
||||
artifactRef.current = artifact;
|
||||
}, [artifact]);
|
||||
|
||||
useEffect(() => {
|
||||
isMutatingRef.current = isMutating;
|
||||
}, [isMutating]);
|
||||
|
||||
useEffect(() => {
|
||||
currentUpdateRef.current = currentUpdate;
|
||||
}, [currentUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
editArtifactRef.current = editArtifact;
|
||||
}, [editArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentCodeRef.current = setCurrentCode;
|
||||
}, [setCurrentCode]);
|
||||
|
||||
/**
|
||||
* Create debounced mutation once - never recreate it
|
||||
* All values are accessed via refs so they're always current
|
||||
*/
|
||||
const debouncedMutation = useMemo(
|
||||
() =>
|
||||
debounce((code: string) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
if (isMutatingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (artifactRef.current.index == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = artifactRef.current;
|
||||
const artifactIndex = artifact.index;
|
||||
const isNotOriginal =
|
||||
code && artifact.content != null && code.trim() !== artifact.content.trim();
|
||||
const isNotRepeated =
|
||||
currentUpdateRef.current == null
|
||||
? true
|
||||
: code != null && code.trim() !== currentUpdateRef.current.trim();
|
||||
|
||||
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
|
||||
setCurrentCodeRef.current(code);
|
||||
editArtifactRef.current.mutate({
|
||||
index: artifactIndex,
|
||||
messageId: artifact.messageId ?? '',
|
||||
original: artifact.content,
|
||||
updated: code,
|
||||
});
|
||||
}
|
||||
}, 500),
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
/**
|
||||
* Listen to Sandpack file changes and trigger debounced mutation
|
||||
*/
|
||||
useEffect(() => {
|
||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
||||
if (currentCode) {
|
||||
debouncedMutation(currentCode);
|
||||
}
|
||||
}, [sandpack.files, fileKey, debouncedMutation]);
|
||||
|
||||
/**
|
||||
* Cleanup: cancel pending mutations when component unmounts or artifact changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedMutation.cancel();
|
||||
};
|
||||
}, [artifact.id, debouncedMutation]);
|
||||
|
||||
return (
|
||||
<SandpackCodeEditor
|
||||
ref={editorRef}
|
||||
showTabs={false}
|
||||
showRunButton={false}
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
readOnly={readOnly === true}
|
||||
extensions={[autocompletion()]}
|
||||
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ArtifactCodeEditor = function ({
|
||||
files,
|
||||
@@ -128,6 +157,7 @@ export const ArtifactCodeEditor = function ({
|
||||
artifact,
|
||||
editorRef,
|
||||
sharedProps,
|
||||
readOnly: externalReadOnly,
|
||||
}: {
|
||||
fileKey: string;
|
||||
artifact: Artifact;
|
||||
@@ -135,6 +165,7 @@ export const ArtifactCodeEditor = function ({
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
@@ -148,10 +179,11 @@ export const ArtifactCodeEditor = function ({
|
||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||
};
|
||||
}, [config, template, fileKey]);
|
||||
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
|
||||
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
|
||||
const [readOnly, setReadOnly] = useState(initialReadOnly);
|
||||
useEffect(() => {
|
||||
setReadOnly(isSubmitting ?? false);
|
||||
}, [isSubmitting]);
|
||||
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
|
||||
}, [isSubmitting, externalReadOnly]);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
SandpackPreview,
|
||||
SandpackProvider,
|
||||
import React, { memo, useMemo, type MutableRefObject } from 'react';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type {
|
||||
SandpackProviderProps,
|
||||
SandpackPreviewRef,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import type { ArtifactFiles } from '~/common';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
@@ -22,7 +21,7 @@ export const ArtifactPreview = memo(function ({
|
||||
fileKey: string;
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
previewRef: MutableRefObject<SandpackPreviewRef>;
|
||||
currentCode?: string;
|
||||
startupConfig?: TStartupConfig;
|
||||
}) {
|
||||
@@ -36,9 +35,7 @@ export const ArtifactPreview = memo(function ({
|
||||
}
|
||||
return {
|
||||
...files,
|
||||
[fileKey]: {
|
||||
code,
|
||||
},
|
||||
[fileKey]: { code },
|
||||
};
|
||||
}, [currentCode, files, fileKey]);
|
||||
|
||||
@@ -46,12 +43,10 @@ export const ArtifactPreview = memo(function ({
|
||||
if (!startupConfig) {
|
||||
return sharedOptions;
|
||||
}
|
||||
const _options: typeof sharedOptions = {
|
||||
return {
|
||||
...sharedOptions,
|
||||
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
|
||||
};
|
||||
|
||||
return _options;
|
||||
}, [startupConfig, template]);
|
||||
|
||||
if (Object.keys(artifactFiles).length === 0) {
|
||||
@@ -60,10 +55,7 @@ export const ArtifactPreview = memo(function ({
|
||||
|
||||
return (
|
||||
<SandpackProvider
|
||||
files={{
|
||||
...artifactFiles,
|
||||
...sharedFiles,
|
||||
}}
|
||||
files={{ ...artifactFiles, ...sharedFiles }}
|
||||
options={options}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useEditorContext, useArtifactsContext } from '~/Providers';
|
||||
import { useCodeState } from '~/Providers/EditorContext';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ArtifactTabs({
|
||||
artifact,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isSharedConvo,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
isSharedConvo?: boolean;
|
||||
}) {
|
||||
const { isSubmitting } = useArtifactsContext();
|
||||
const { currentCode, setCurrentCode } = useEditorContext();
|
||||
const { currentCode, setCurrentCode } = useCodeState();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const lastIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.id !== lastIdRef.current) {
|
||||
setCurrentCode(undefined);
|
||||
@@ -33,14 +37,16 @@ export default function ArtifactTabs({
|
||||
const content = artifact.content ?? '';
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
||||
|
||||
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Tabs.Content
|
||||
ref={contentRef}
|
||||
value="code"
|
||||
id="artifacts-code"
|
||||
className={cn('flex-grow overflow-auto')}
|
||||
className="h-full w-full flex-grow overflow-auto"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArtifactCodeEditor
|
||||
@@ -50,9 +56,11 @@ export default function ArtifactTabs({
|
||||
artifact={artifact}
|
||||
editorRef={editorRef}
|
||||
sharedProps={sharedProps}
|
||||
readOnly={isSharedConvo}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
|
||||
|
||||
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
|
||||
<ArtifactPreview
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
@@ -63,6 +71,6 @@ export default function ArtifactTabs({
|
||||
startupConfig={startupConfig}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
79
client/src/components/Artifacts/ArtifactVersion.tsx
Normal file
79
client/src/components/Artifacts/ArtifactVersion.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MenuButton } from '@ariakit/react';
|
||||
import { History, Check } from 'lucide-react';
|
||||
import { DropdownPopup, TooltipAnchor, Button, useMediaQuery } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface ArtifactVersionProps {
|
||||
currentIndex: number;
|
||||
totalVersions: number;
|
||||
onVersionChange: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function ArtifactVersion({
|
||||
currentIndex,
|
||||
totalVersions,
|
||||
onVersionChange,
|
||||
}: ArtifactVersionProps) {
|
||||
const localize = useLocalize();
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const menuId = 'version-dropdown-menu';
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
const index = parseInt(value, 10);
|
||||
onVersionChange(index);
|
||||
setIsPopoverActive(false);
|
||||
};
|
||||
|
||||
if (totalVersions <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = Array.from({ length: totalVersions }, (_, index) => ({
|
||||
value: index.toString(),
|
||||
label: localize('com_ui_version_var', { 0: String(index + 1) }),
|
||||
}));
|
||||
|
||||
const dropdownItems = options.map((option) => {
|
||||
const isSelected = option.value === String(currentIndex);
|
||||
return {
|
||||
label: option.label,
|
||||
onClick: () => handleValueChange(option.value),
|
||||
value: option.value,
|
||||
icon: isSelected ? (
|
||||
<Check size={16} className="text-text-primary" aria-hidden="true" />
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownPopup
|
||||
menuId={menuId}
|
||||
portal
|
||||
focusLoop
|
||||
unmountOnHide
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_change_version')}
|
||||
render={
|
||||
<Button size="icon" variant="ghost" asChild>
|
||||
<MenuButton>
|
||||
<History
|
||||
size={18}
|
||||
className="text-text-secondary"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
</MenuButton>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
items={dropdownItems}
|
||||
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +1,334 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
|
||||
import { Code, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import { useShareContext, useMutationState } from '~/Providers';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import ArtifactVersion from './ArtifactVersion';
|
||||
import ArtifactTabs from './ArtifactTabs';
|
||||
import { CopyCodeButton } from './Code';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const MAX_BLUR_AMOUNT = 32;
|
||||
const MAX_BACKDROP_OPACITY = 0.3;
|
||||
|
||||
export default function Artifacts() {
|
||||
const localize = useLocalize();
|
||||
const { isMutating } = useEditorContext();
|
||||
const { isMutating } = useMutationState();
|
||||
const { isSharedConvo } = useShareContext();
|
||||
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||
const editorRef = useRef<CodeEditorRef>();
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [height, setHeight] = useState(90);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [blurAmount, setBlurAmount] = useState(0);
|
||||
const dragStartY = useRef(0);
|
||||
const dragStartHeight = useRef(90);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
value: 'code',
|
||||
label: localize('com_ui_code'),
|
||||
icon: <Code className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: 'preview',
|
||||
label: localize('com_ui_preview'),
|
||||
icon: <Play className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
setIsMounted(true);
|
||||
const delay = isMobile ? 50 : 30;
|
||||
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
setIsMounted(false);
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setBlurAmount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const minHeightForBlur = 50;
|
||||
const maxHeightForBlur = 100;
|
||||
|
||||
if (height <= minHeightForBlur) {
|
||||
setBlurAmount(0);
|
||||
} else if (height >= maxHeightForBlur) {
|
||||
setBlurAmount(MAX_BLUR_AMOUNT);
|
||||
} else {
|
||||
const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur);
|
||||
setBlurAmount(Math.round(progress * MAX_BLUR_AMOUNT));
|
||||
}
|
||||
}, [height, isMobile]);
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
} = useArtifacts();
|
||||
|
||||
if (currentArtifact === null || currentArtifact === undefined) {
|
||||
const handleDragStart = (e: React.PointerEvent) => {
|
||||
setIsDragging(true);
|
||||
dragStartY.current = e.clientY;
|
||||
dragStartHeight.current = height;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handleDragMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaY = dragStartY.current - e.clientY;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const deltaPercentage = (deltaY / viewportHeight) * 100;
|
||||
const newHeight = Math.max(10, Math.min(100, dragStartHeight.current + deltaPercentage));
|
||||
|
||||
setHeight(newHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: React.PointerEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
|
||||
// Snap to positions based on final height
|
||||
if (height < 30) {
|
||||
closeArtifacts();
|
||||
} else if (height > 95) {
|
||||
setHeight(100);
|
||||
} else if (height < 60) {
|
||||
setHeight(50);
|
||||
} else {
|
||||
setHeight(90);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentArtifact || !isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
const client = previewRef.current?.getClient();
|
||||
if (client != null) {
|
||||
if (client) {
|
||||
client.dispatch({ type: 'refresh' });
|
||||
}
|
||||
setTimeout(() => setIsRefreshing(false), 750);
|
||||
};
|
||||
|
||||
const closeArtifacts = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
if (isMobile) {
|
||||
setIsClosing(true);
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setArtifactsVisible(false);
|
||||
setIsClosing(false);
|
||||
setHeight(90);
|
||||
}, 250);
|
||||
} else {
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const backdropOpacity =
|
||||
blurAmount > 0
|
||||
? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
{/* Main Parent */}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* Main Container */}
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* Mobile backdrop with dynamic blur */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
|
||||
isVisible && !isClosing
|
||||
? 'transition-all duration-300'
|
||||
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
|
||||
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
|
||||
)}
|
||||
style={{
|
||||
opacity: isVisible && !isClosing ? backdropOpacity : 0,
|
||||
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
}}
|
||||
onClick={blurAmount >= 8 ? closeArtifacts : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out`,
|
||||
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm',
|
||||
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
|
||||
isMobile
|
||||
? cn(
|
||||
'fixed inset-x-0 bottom-0 z-[100] rounded-t-[20px] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
|
||||
isVisible && !isClosing
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'duration-250 translate-y-full opacity-0 transition-all',
|
||||
isDragging ? '' : 'transition-all duration-300',
|
||||
)
|
||||
: cn(
|
||||
'h-full shadow-2xl',
|
||||
isVisible && !isClosing
|
||||
? 'duration-350 translate-x-0 opacity-100 transition-all'
|
||||
: 'translate-x-5 opacity-0 transition-all duration-300',
|
||||
),
|
||||
)}
|
||||
style={isMobile ? { height: `${height}vh` } : { overflow: 'hidden' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
|
||||
<div className="flex items-center">
|
||||
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
|
||||
{isMobile && (
|
||||
<div
|
||||
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
>
|
||||
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Refresh button */}
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
|
||||
isMobile ? 'justify-center' : 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center transition-all duration-500',
|
||||
isVisible && !isClosing
|
||||
? 'translate-x-0 opacity-100'
|
||||
: '-translate-x-2 opacity-0',
|
||||
)}
|
||||
>
|
||||
<Radio
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 transition-all duration-500',
|
||||
isMobile ? 'min-w-max' : '',
|
||||
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
|
||||
)}
|
||||
>
|
||||
{activeTab === 'preview' && (
|
||||
<button
|
||||
className={cn(
|
||||
'mr-2 text-text-secondary transition-transform duration-500 ease-in-out',
|
||||
isRefreshing ? 'rotate-180' : '',
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label="Refresh"
|
||||
aria-label={localize('com_ui_refresh')}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={cn('transform', isRefreshing ? 'animate-spin' : '')}
|
||||
/>
|
||||
</button>
|
||||
{isRefreshing ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<RefreshCw size={16} className="transition-transform duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{activeTab !== 'preview' && isMutating && (
|
||||
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
|
||||
<RefreshCw size={16} className="animate-spin text-text-secondary" />
|
||||
)}
|
||||
{orderedArtifactIds.length > 1 && (
|
||||
<ArtifactVersion
|
||||
currentIndex={currentIndex}
|
||||
totalVersions={orderedArtifactIds.length}
|
||||
onVersionChange={(index) => {
|
||||
const target = orderedArtifactIds[index];
|
||||
if (target) {
|
||||
setCurrentArtifactId(target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
|
||||
<Tabs.Trigger
|
||||
value="preview"
|
||||
disabled={isMutating}
|
||||
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
|
||||
>
|
||||
{localize('com_ui_preview')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="code"
|
||||
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
|
||||
>
|
||||
{localize('com_ui_code')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<button className="text-text-secondary" onClick={closeArtifacts}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
/>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
|
||||
<div className="flex items-center">
|
||||
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs">{`${currentIndex + 1} / ${
|
||||
orderedArtifactIds.length
|
||||
}`}</span>
|
||||
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||
{/* Download Button */}
|
||||
<DownloadArtifact artifact={currentArtifact} />
|
||||
{/* Publish button */}
|
||||
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
|
||||
Publish
|
||||
</button> */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={closeArtifacts}
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
isSharedConvo={isSharedConvo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
|
||||
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!isRefreshing}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isRefreshing ? 'scale-100' : 'scale-95',
|
||||
)}
|
||||
>
|
||||
<Spinner size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
|
||||
<Radio
|
||||
fullWidth
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
@@ -2,8 +2,9 @@ import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Button } from '@librechat/client';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { Clipboard, CheckMark } from '@librechat/client';
|
||||
import { Copy, CircleCheckBig } from 'lucide-react';
|
||||
import { handleDoubleClick, langSubset } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
@@ -107,12 +108,13 @@ export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="mr-2 text-text-secondary"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
</button>
|
||||
{isCopied ? <CircleCheckBig size={16} /> : <Copy size={16} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, CircleCheckBig } from 'lucide-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import { CheckMark } from '@librechat/client';
|
||||
import { Button } from '@librechat/client';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import { useCodeState } from '~/Providers/EditorContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DownloadArtifact = ({
|
||||
artifact,
|
||||
className = '',
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
className?: string;
|
||||
}) => {
|
||||
const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
|
||||
const localize = useLocalize();
|
||||
const { currentCode } = useEditorContext();
|
||||
const { currentCode } = useCodeState();
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
const { fileKey: fileName } = useArtifactProps({ artifact });
|
||||
|
||||
@@ -41,13 +35,14 @@ const DownloadArtifact = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`mr-2 text-text-secondary ${className}`}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleDownload}
|
||||
aria-label={localize('com_ui_download_artifact')}
|
||||
>
|
||||
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
||||
</button>
|
||||
{isDownloaded ? <CircleCheckBig size={16} /> : <Download size={16} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'items-between flex gap-2 pb-2',
|
||||
'@container items-between flex gap-2 pb-2',
|
||||
isRTL ? 'flex-row-reverse' : 'flex-row',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
getEndpointField,
|
||||
isAssistantsEndpoint,
|
||||
fileConfig as defaultFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig, TConversation } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField } from '~/utils/endpoints';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import AttachFile from './AttachFile';
|
||||
|
||||
@@ -26,7 +26,7 @@ function AttachFileChat({
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
|
||||
const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]);
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
@@ -39,9 +39,23 @@ function AttachFileChat({
|
||||
);
|
||||
}, [endpoint, endpointsConfig]);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined;
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
||||
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
|
||||
const endpointFileConfig = useMemo(
|
||||
() =>
|
||||
getEndpointFileConfig({
|
||||
endpoint,
|
||||
fileConfig,
|
||||
endpointType,
|
||||
}),
|
||||
[endpoint, fileConfig, endpointType],
|
||||
);
|
||||
const endpointSupportsFiles: boolean = useMemo(
|
||||
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,
|
||||
[endpointType, endpoint],
|
||||
);
|
||||
const isUploadDisabled = useMemo(
|
||||
() => (disableInputs || endpointFileConfig?.disabled) ?? false,
|
||||
[disableInputs, endpointFileConfig?.disabled],
|
||||
);
|
||||
|
||||
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
|
||||
return <AttachFile disabled={disableInputs} />;
|
||||
|
||||
@@ -61,13 +61,8 @@ const AttachFileMenu = ({
|
||||
ephemeralAgentByConvoId(conversationId),
|
||||
);
|
||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
overrideEndpointFileConfig: endpointFileConfig,
|
||||
});
|
||||
const { handleFileChange } = useFileHandling();
|
||||
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
overrideEndpointFileConfig: endpointFileConfig,
|
||||
toolResource,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useState } from 'react';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||
import { PluginStoreDialog, TooltipAnchor } from '@librechat/client';
|
||||
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
|
||||
import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider';
|
||||
import { isParamEndpoint, getEndpointField, tConvoUpdateSchema } from 'librechat-data-provider';
|
||||
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
|
||||
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
|
||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
||||
@@ -12,8 +10,6 @@ import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import OptionsPopover from './OptionsPopover';
|
||||
import PopoverButtons from './PopoverButtons';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { getEndpointField } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function HeaderOptions({
|
||||
interfaceConfig,
|
||||
@@ -23,36 +19,11 @@ export default function HeaderOptions({
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
|
||||
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
|
||||
store.showPluginStoreDialog,
|
||||
);
|
||||
const localize = useLocalize();
|
||||
|
||||
const { showPopover, conversation, setShowPopover } = useChatContext();
|
||||
const { setOption } = useSetIndexOptions();
|
||||
const { endpoint, conversationId } = conversation ?? {};
|
||||
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
|
||||
const userProvidesKey = useMemo(
|
||||
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
|
||||
[endpointsConfig, endpoint],
|
||||
);
|
||||
const keyProvided = useMemo(
|
||||
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
|
||||
[keyExpiry.expiresAt, userProvidesKey],
|
||||
);
|
||||
|
||||
const noSettings = useMemo<{ [key: string]: boolean }>(
|
||||
() => ({
|
||||
[EModelEndpoint.chatGPTBrowser]: true,
|
||||
}),
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (endpoint && noSettings[endpoint]) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
}, [endpoint, noSettings]);
|
||||
const { endpoint } = conversation ?? {};
|
||||
|
||||
const saveAsPreset = () => {
|
||||
setSaveAsDialogShow(true);
|
||||
@@ -76,22 +47,20 @@ export default function HeaderOptions({
|
||||
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
|
||||
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
||||
<div className="z-[61] flex w-full items-center justify-center gap-2">
|
||||
{!noSettings[endpoint] &&
|
||||
interfaceConfig?.parameters === true &&
|
||||
paramEndpoint === false && (
|
||||
<TooltipAnchor
|
||||
id="parameters-button"
|
||||
aria-label={localize('com_ui_model_parameters')}
|
||||
description={localize('com_ui_model_parameters')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={triggerAdvancedMode}
|
||||
data-testid="parameters-button"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
{interfaceConfig?.parameters === true && paramEndpoint === false && (
|
||||
<TooltipAnchor
|
||||
id="parameters-button"
|
||||
aria-label={localize('com_ui_model_parameters')}
|
||||
description={localize('com_ui_model_parameters')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={triggerAdvancedMode}
|
||||
data-testid="parameters-button"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
</div>
|
||||
{interfaceConfig?.parameters === true && paramEndpoint === false && (
|
||||
<OptionsPopover
|
||||
@@ -122,12 +91,6 @@ export default function HeaderOptions({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{interfaceConfig?.parameters === true && (
|
||||
<PluginStoreDialog
|
||||
isOpen={showPluginStoreDialog}
|
||||
setIsOpen={setShowPluginStoreDialog}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Anchor>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { getEndpointField } from '~/utils';
|
||||
|
||||
interface DialogManagerProps {
|
||||
keyDialogOpen: boolean;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { memo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { IconMapProps } from '~/common';
|
||||
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
|
||||
import { getModelSpecIconURL, getIconKey } from '~/utils';
|
||||
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Close } from '@radix-ui/react-popover';
|
||||
import { Flipper, Flipped } from 'react-flip-toolkit';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Label,
|
||||
DialogTemplate,
|
||||
PinIcon,
|
||||
EditIcon,
|
||||
TrashIcon,
|
||||
DialogTrigger,
|
||||
DialogTemplate,
|
||||
} from '@librechat/client';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import { getPresetTitle, getEndpointField, getIconKey } from '~/utils';
|
||||
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getPresetTitle, getIconKey } from '~/utils';
|
||||
import { MenuSeparator, MenuItem } from '../UI';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
@@ -144,6 +144,7 @@ const Part = memo(
|
||||
attachments={attachments}
|
||||
auth={toolCall.auth}
|
||||
expires_at={toolCall.expires_at}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
|
||||
@@ -192,6 +193,7 @@ const Part = memo(
|
||||
args={toolCall.function.arguments as string}
|
||||
name={toolCall.function.name}
|
||||
output={toolCall.function.output}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,25 +68,27 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||
|
||||
return (
|
||||
<div className="group/reasoning">
|
||||
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={reasoningText}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
<div className="group/thinking-container">
|
||||
<div className="sticky top-0 z-10 mb-2 bg-presentation pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={reasoningText}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,11 +35,13 @@ export const ThinkingButton = memo(
|
||||
onClick,
|
||||
label,
|
||||
content,
|
||||
showCopyButton = true,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
content?: string;
|
||||
showCopyButton?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
@@ -59,7 +61,7 @@ export const ThinkingButton = memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="group/thinking flex w-full items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
@@ -79,7 +81,7 @@ export const ThinkingButton = memo(
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{content && (
|
||||
{content && showCopyButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
@@ -90,8 +92,11 @@ export const ThinkingButton = memo(
|
||||
}
|
||||
className={cn(
|
||||
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
isExpanded
|
||||
? 'opacity-0 group-focus-within/thinking-container:opacity-100 group-hover/thinking-container:opacity-100'
|
||||
: 'opacity-0',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
)}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
@@ -142,8 +147,8 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||
<div className="group/thinking-container">
|
||||
<div className="sticky top-0 z-10 mb-4 bg-presentation pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
@@ -161,7 +166,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
||||
<ThinkingContent>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { logger, cn } from '~/utils';
|
||||
|
||||
export default function ToolCall({
|
||||
initialProgress = 0.1,
|
||||
isLast = false,
|
||||
isSubmitting,
|
||||
name,
|
||||
args: _args = '',
|
||||
@@ -19,6 +20,7 @@ export default function ToolCall({
|
||||
auth,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isLast?: boolean;
|
||||
isSubmitting: boolean;
|
||||
name: string;
|
||||
args: string | Record<string, unknown>;
|
||||
@@ -155,6 +157,10 @@ export default function ToolCall({
|
||||
};
|
||||
}, [showInfo, isAnimating]);
|
||||
|
||||
if (!isLast && (!function_name || function_name.length === 0) && !output) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative my-2.5 flex h-5 shrink-0 items-center gap-2.5">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useMemo, memo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import type { Assistant, Agent } from 'librechat-data-provider';
|
||||
import type { TMessageIcon } from '~/common';
|
||||
import { getEndpointField, getIconEndpoint, logger } from '~/utils';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getIconEndpoint, logger } from '~/utils';
|
||||
import Icon from '~/components/Endpoints/Icon';
|
||||
|
||||
const MessageIcon = memo(
|
||||
|
||||
@@ -18,16 +18,20 @@ export default function MinimalHoverButtons({ message, searchResults }: THoverBu
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||
<div className="visible mt-1 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||
<button
|
||||
className="ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
|
||||
className="ml-0 flex items-center gap-1.5 rounded-lg p-1.5 text-xs text-text-secondary-alt transition-colors duration-200 hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
|
||||
onClick={() => copyToClipboard(setIsCopied)}
|
||||
type="button"
|
||||
title={
|
||||
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
|
||||
}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
{isCopied ? (
|
||||
<CheckMark className="h-[19px] w-[19px]" />
|
||||
) : (
|
||||
<Clipboard className="h-[19px] w-[19px]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,23 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
||||
}, []);
|
||||
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
||||
|
||||
/**
|
||||
* Memoize artifacts JSX to prevent recreating it on every render
|
||||
* This is critical for performance - prevents entire artifact tree from re-rendering
|
||||
*/
|
||||
const artifactsElement = useMemo(() => {
|
||||
if (artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0) {
|
||||
return (
|
||||
<ArtifactsProvider>
|
||||
<EditorProvider>
|
||||
<Artifacts />
|
||||
</EditorProvider>
|
||||
</ArtifactsProvider>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [artifactsVisibility, artifacts]);
|
||||
|
||||
return (
|
||||
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
|
||||
<SidePanelProvider>
|
||||
@@ -64,15 +81,7 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
||||
defaultLayout={defaultLayout}
|
||||
fullPanelCollapse={fullCollapse}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
artifacts={
|
||||
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
|
||||
<ArtifactsProvider>
|
||||
<EditorProvider>
|
||||
<Artifacts />
|
||||
</EditorProvider>
|
||||
</ArtifactsProvider>
|
||||
) : null
|
||||
}
|
||||
artifacts={artifactsElement}
|
||||
>
|
||||
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils';
|
||||
import { getIconKey, getEntity, getIconEndpoint } from '~/utils';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type {
|
||||
TConversation,
|
||||
TEndpointsConfig,
|
||||
TPreset,
|
||||
TConversation,
|
||||
TAssistantsMap,
|
||||
TEndpointsConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
|
||||
import { getEndpointField, getIconEndpoint } from '~/utils';
|
||||
import { getIconEndpoint } from '~/utils';
|
||||
|
||||
export default function EndpointIcon({
|
||||
conversation,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { SettingsViews, TConversation } from 'librechat-data-provider';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import { getEndpointField, SettingsViews } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { TSettingsProps } from '~/common';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { cn, getEndpointField } from '~/utils';
|
||||
import { getSettings } from './Settings';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Settings({
|
||||
|
||||
@@ -25,7 +25,7 @@ type EndpointIcon = {
|
||||
|
||||
function getOpenAIColor(_model: string | null | undefined) {
|
||||
const model = _model?.toLowerCase() ?? '';
|
||||
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) {
|
||||
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9](?:\.\d+)?\b/i.test(model))) {
|
||||
return '#000000';
|
||||
}
|
||||
return model.includes('gpt-4') ? '#AB68FF' : '#19C37D';
|
||||
|
||||
@@ -43,6 +43,7 @@ const errorMessages = {
|
||||
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
|
||||
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
|
||||
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
|
||||
[ErrorTypes.REFUSAL]: 'com_error_refusal',
|
||||
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info: endpoint } = json;
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
|
||||
@@ -68,9 +68,11 @@ export const ThemeSelector = ({
|
||||
export const LangSelector = ({
|
||||
langcode,
|
||||
onChange,
|
||||
portal = true,
|
||||
}: {
|
||||
langcode: string;
|
||||
onChange: (value: string) => void;
|
||||
portal?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
@@ -124,10 +126,11 @@ export const LangSelector = ({
|
||||
<Dropdown
|
||||
value={langcode}
|
||||
onChange={onChange}
|
||||
sizeClasses="[--anchor-max-height:256px]"
|
||||
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
|
||||
options={languageOptions}
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
portal={portal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { TMessage, Assistant, Agent } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import { getIconEndpoint } from '~/utils';
|
||||
import { getIconEndpoint, logger } from '~/utils';
|
||||
|
||||
export default function MessageIcon(
|
||||
props: Pick<TMessageProps, 'message' | 'conversation'> & {
|
||||
@@ -41,7 +41,7 @@ export default function MessageIcon(
|
||||
}
|
||||
return result;
|
||||
}, [assistant, agent, assistantAvatar, agentAvatar]);
|
||||
console.log('MessageIcon', {
|
||||
logger.log('MessageIcon', {
|
||||
endpoint,
|
||||
iconURL,
|
||||
assistantName,
|
||||
|
||||
176
client/src/components/Share/ShareArtifacts.tsx
Normal file
176
client/src/components/Share/ShareArtifacts.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
useMediaQuery,
|
||||
ResizablePanel,
|
||||
ResizableHandleAlt,
|
||||
ResizablePanelGroup,
|
||||
} from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { ArtifactsContextValue } from '~/Providers';
|
||||
import { ArtifactsProvider, EditorProvider } from '~/Providers';
|
||||
import Artifacts from '~/components/Artifacts/Artifacts';
|
||||
import { getLatestText } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const DEFAULT_ARTIFACT_PANEL_SIZE = 40;
|
||||
const SHARE_ARTIFACT_PANEL_STORAGE_KEY = 'share:artifacts-panel-size';
|
||||
const SHARE_ARTIFACT_PANEL_DEFAULT_KEY = 'share:artifacts-panel-size-default';
|
||||
|
||||
/**
|
||||
* Gets the initial artifact panel size from localStorage or returns default
|
||||
*/
|
||||
const getInitialArtifactPanelSize = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_ARTIFACT_PANEL_SIZE;
|
||||
}
|
||||
|
||||
const defaultSizeString = String(DEFAULT_ARTIFACT_PANEL_SIZE);
|
||||
const storedDefault = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY);
|
||||
|
||||
if (storedDefault !== defaultSizeString) {
|
||||
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY, defaultSizeString);
|
||||
window.localStorage.removeItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
|
||||
return DEFAULT_ARTIFACT_PANEL_SIZE;
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
|
||||
const parsed = Number(stored);
|
||||
return Number.isFinite(parsed) ? parsed : DEFAULT_ARTIFACT_PANEL_SIZE;
|
||||
};
|
||||
|
||||
interface ShareArtifactsContainerProps {
|
||||
messages: TMessage[];
|
||||
conversationId: string;
|
||||
mainContent: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container component that manages artifact visibility and layout for shared conversations
|
||||
*/
|
||||
export function ShareArtifactsContainer({
|
||||
messages,
|
||||
conversationId,
|
||||
mainContent,
|
||||
}: ShareArtifactsContainerProps) {
|
||||
const artifacts = useRecoilValue(store.artifactsState);
|
||||
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 1023px)');
|
||||
const [artifactPanelSize, setArtifactPanelSize] = useState(getInitialArtifactPanelSize);
|
||||
|
||||
const artifactsContextValue = useMemo<ArtifactsContextValue | null>(() => {
|
||||
const latestMessage =
|
||||
Array.isArray(messages) && messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
|
||||
if (!latestMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestMessageText = getLatestText(latestMessage);
|
||||
|
||||
return {
|
||||
isSubmitting: false,
|
||||
latestMessageId: latestMessage.messageId ?? null,
|
||||
latestMessageText,
|
||||
conversationId: conversationId ?? null,
|
||||
};
|
||||
}, [messages, conversationId]);
|
||||
|
||||
const shouldRenderArtifacts =
|
||||
artifactsVisibility === true &&
|
||||
artifactsContextValue != null &&
|
||||
Object.keys(artifacts ?? {}).length > 0;
|
||||
|
||||
const normalizedArtifactSize = Math.min(60, Math.max(20, artifactPanelSize));
|
||||
|
||||
/**
|
||||
* Handles artifact panel resize and persists size to localStorage
|
||||
*/
|
||||
const handleLayoutChange = (sizes: number[]) => {
|
||||
if (sizes.length < 2) {
|
||||
return;
|
||||
}
|
||||
const newSize = sizes[1];
|
||||
if (!Number.isFinite(newSize)) {
|
||||
return;
|
||||
}
|
||||
setArtifactPanelSize(newSize);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY, newSize.toString());
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldRenderArtifacts || !artifactsContextValue) {
|
||||
return <>{mainContent}</>;
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{mainContent}
|
||||
<ShareArtifactsOverlay contextValue={artifactsContextValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="flex h-full w-full"
|
||||
onLayout={handleLayoutChange}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - normalizedArtifactSize}
|
||||
minSize={35}
|
||||
order={1}
|
||||
id="share-content"
|
||||
>
|
||||
{mainContent}
|
||||
</ResizablePanel>
|
||||
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
|
||||
<ResizablePanel
|
||||
defaultSize={normalizedArtifactSize}
|
||||
minSize={20}
|
||||
maxSize={60}
|
||||
order={2}
|
||||
id="share-artifacts"
|
||||
>
|
||||
<ShareArtifactsPanel contextValue={artifactsContextValue} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShareArtifactsPanelProps {
|
||||
contextValue: ArtifactsContextValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel that renders the artifacts UI within a resizable container
|
||||
*/
|
||||
function ShareArtifactsPanel({ contextValue }: ShareArtifactsPanelProps) {
|
||||
return (
|
||||
<ArtifactsProvider value={contextValue}>
|
||||
<EditorProvider>
|
||||
<div className="flex h-full w-full border-l border-border-light bg-surface-primary shadow-2xl">
|
||||
<Artifacts />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</ArtifactsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile overlay that displays artifacts in a fixed position
|
||||
*/
|
||||
function ShareArtifactsOverlay({ contextValue }: ShareArtifactsPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 z-40 flex w-full max-w-full sm:max-w-[420px]"
|
||||
role="complementary"
|
||||
aria-label="Artifacts panel"
|
||||
>
|
||||
<ShareArtifactsPanel contextValue={contextValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,42 @@
|
||||
import { memo } from 'react';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { memo, useState, useCallback, useContext } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { buildTree } from 'librechat-data-provider';
|
||||
import { CalendarDays, Settings } from 'lucide-react';
|
||||
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Spinner,
|
||||
Button,
|
||||
OGDialog,
|
||||
ThemeContext,
|
||||
OGDialogTitle,
|
||||
useMediaQuery,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
} from '@librechat/client';
|
||||
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
|
||||
import { ShareArtifactsContainer } from './ShareArtifacts';
|
||||
import { useLocalize, useDocumentTitle } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ShareContext } from '~/Providers';
|
||||
import MessagesView from './MessagesView';
|
||||
import Footer from '../Chat/Footer';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function SharedView() {
|
||||
const localize = useLocalize();
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const { shareId } = useParams();
|
||||
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
||||
const dataTree = data && buildTree({ messages: data.messages });
|
||||
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
|
||||
|
||||
const [langcode, setLangcode] = useRecoilState(store.lang);
|
||||
|
||||
// configure document title
|
||||
let docTitle = '';
|
||||
if (config?.appTitle != null && data?.title != null) {
|
||||
@@ -27,6 +47,48 @@ function SharedView() {
|
||||
|
||||
useDocumentTitle(docTitle);
|
||||
|
||||
const locale =
|
||||
langcode ||
|
||||
(typeof navigator !== 'undefined'
|
||||
? navigator.language || navigator.languages?.[0] || 'en-US'
|
||||
: 'en-US');
|
||||
|
||||
const formattedDate =
|
||||
data?.createdAt != null
|
||||
? new Date(data.createdAt).toLocaleDateString(locale, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
: null;
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(value: string) => {
|
||||
setTheme(value);
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
|
||||
const handleLangChange = useCallback(
|
||||
(value: string) => {
|
||||
let userLang = value;
|
||||
if (value === 'auto') {
|
||||
userLang =
|
||||
(typeof navigator !== 'undefined'
|
||||
? navigator.language || navigator.languages?.[0]
|
||||
: null) ?? 'en-US';
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.lang = userLang;
|
||||
});
|
||||
|
||||
setLangcode(userLang);
|
||||
Cookies.set('lang', userLang, { expires: 365 });
|
||||
},
|
||||
[setLangcode],
|
||||
);
|
||||
|
||||
let content: JSX.Element;
|
||||
if (isLoading) {
|
||||
content = (
|
||||
@@ -37,17 +99,15 @@ function SharedView() {
|
||||
} else if (data && messagesTree && messagesTree.length !== 0) {
|
||||
content = (
|
||||
<>
|
||||
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-[47rem] md:px-5 lg:px-1 xl:max-w-[55rem] xl:px-5">
|
||||
<h1 className="text-4xl font-bold">{data.title}</h1>
|
||||
<div className="border-b border-border-medium pb-6 text-base text-text-secondary">
|
||||
{new Date(data.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareHeader
|
||||
title={data.title}
|
||||
formattedDate={formattedDate}
|
||||
theme={theme}
|
||||
langcode={langcode}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLangChange={handleLangChange}
|
||||
settingsLabel={localize('com_nav_settings')}
|
||||
/>
|
||||
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
|
||||
</>
|
||||
);
|
||||
@@ -59,23 +119,124 @@ function SharedView() {
|
||||
);
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||
<Footer className="relative mx-auto mt-4 flex max-w-[55rem] flex-wrap items-center justify-center gap-2 px-3 pb-4 pt-2 text-center text-xs text-text-secondary" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const mainContent = (
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
|
||||
<div className="flex h-full flex-col text-text-primary" role="presentation">
|
||||
{content}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const artifactsContainer =
|
||||
data && data.messages ? (
|
||||
<ShareArtifactsContainer
|
||||
messages={data.messages}
|
||||
conversationId={data.conversationId}
|
||||
mainContent={mainContent}
|
||||
/>
|
||||
) : (
|
||||
mainContent
|
||||
);
|
||||
|
||||
return (
|
||||
<ShareContext.Provider value={{ isSharedConvo: true }}>
|
||||
<main
|
||||
className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary"
|
||||
style={{ paddingBottom: '50px' }}
|
||||
>
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
|
||||
<div className="flex h-full flex-col text-text-primary" role="presentation">
|
||||
{content}
|
||||
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||
<Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-surface-secondary to-transparent px-2 pb-2 pt-8 text-xs text-text-secondary md:px-[60px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div className="relative flex min-h-screen w-full dark:bg-surface-secondary">
|
||||
<main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
|
||||
{artifactsContainer}
|
||||
</main>
|
||||
</div>
|
||||
</ShareContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShareHeaderProps {
|
||||
title?: string;
|
||||
formattedDate: string | null;
|
||||
theme: string;
|
||||
langcode: string;
|
||||
settingsLabel: string;
|
||||
onThemeChange: (value: string) => void;
|
||||
onLangChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function ShareHeader({
|
||||
title,
|
||||
formattedDate,
|
||||
theme,
|
||||
langcode,
|
||||
settingsLabel,
|
||||
onThemeChange,
|
||||
onLangChange,
|
||||
}: ShareHeaderProps) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
const handleDialogOutside = useCallback((event: Event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-dialog-ignore="true"]')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full px-3 pb-4 pt-6 md:px-5">
|
||||
<div className="bg-surface-primary/80 relative mx-auto flex w-full max-w-[60rem] flex-col gap-4 rounded-3xl border border-border-light px-6 py-5 shadow-xl backdrop-blur">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-semibold text-text-primary">{title}</h1>
|
||||
{formattedDate && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<CalendarDays className="size-4" aria-hidden="true" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OGDialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={isMobile ? 'icon' : 'default'}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={settingsLabel}
|
||||
className={cn(
|
||||
'rounded-full border-border-medium text-sm text-text-primary transition-colors',
|
||||
isMobile
|
||||
? 'absolute bottom-4 right-4 justify-center p-0 shadow-lg'
|
||||
: 'gap-2 self-start px-4 py-2',
|
||||
)}
|
||||
>
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
<span className="hidden md:inline">{settingsLabel}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent
|
||||
className="w-11/12 max-w-lg"
|
||||
showCloseButton={true}
|
||||
onPointerDownOutside={handleDialogOutside}
|
||||
onInteractOutside={handleDialogOutside}
|
||||
>
|
||||
<OGDialogHeader className="text-left">
|
||||
<OGDialogTitle>{settingsLabel}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2 text-sm">
|
||||
<ThemeSelector theme={theme} onChange={onThemeChange} />
|
||||
<div className="bg-border-medium/60 h-px w-full" />
|
||||
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SharedView);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
||||
import {
|
||||
removeFocusOutlines,
|
||||
processAgentOption,
|
||||
getEndpointField,
|
||||
defaultTextProps,
|
||||
validateEmail,
|
||||
getIconKey,
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
AgentCapabilities,
|
||||
fileConfig as defaultFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, AgentForm } from '~/common';
|
||||
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
|
||||
import FileRow from '~/components/Chat/Input/Files/FileRow';
|
||||
@@ -30,12 +29,11 @@ export default function Files({
|
||||
const { watch } = useFormContext<AgentForm>();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const { abortUpload, handleFileChange } = useFileHandling({
|
||||
fileSetter: setFiles,
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
additionalMetadata: { agent_id, tool_resource },
|
||||
});
|
||||
|
||||
@@ -51,9 +49,11 @@ export default function Files({
|
||||
|
||||
const codeChecked = watch(AgentCapabilities.execute_code);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents] as
|
||||
| EndpointFileConfig
|
||||
| undefined;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
endpointType: EModelEndpoint.agents,
|
||||
});
|
||||
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
|
||||
|
||||
if (isUploadDisabled) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
mergeFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
HoverCard,
|
||||
@@ -41,17 +41,15 @@ export default function FileContext({
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
additionalMetadata: { agent_id, tool_resource: EToolResources.context },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
@@ -65,8 +63,12 @@ export default function FileContext({
|
||||
750,
|
||||
);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
|
||||
const isUploadDisabled = endpointFileConfig.disabled ?? false;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
endpointType: EModelEndpoint.agents,
|
||||
});
|
||||
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
|
||||
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
|
||||
try {
|
||||
await handleSharePointFiles(sharePointFiles);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
EToolResources,
|
||||
mergeFileConfig,
|
||||
AgentCapabilities,
|
||||
fileConfig as defaultFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ExtendedFile, AgentForm } from '~/common';
|
||||
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
|
||||
@@ -38,18 +38,16 @@ export default function FileSearch({
|
||||
// Get startup configuration for SharePoint feature flag
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
|
||||
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
@@ -66,8 +64,12 @@ export default function FileSearch({
|
||||
|
||||
const fileSearchChecked = watch(AgentCapabilities.file_search);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
|
||||
const isUploadDisabled = endpointFileConfig.disabled ?? false;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
endpointType: EModelEndpoint.agents,
|
||||
});
|
||||
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
|
||||
|
||||
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
|
||||
const disabledUploadButton = isEphemeralAgent(agent_id) || fileSearchChecked === false;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import {
|
||||
alternateName,
|
||||
getSettingsKeys,
|
||||
getEndpointField,
|
||||
LocalStorageKeys,
|
||||
SettingDefinition,
|
||||
agentParamSettings,
|
||||
@@ -14,9 +15,9 @@ import {
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ModelPanel({
|
||||
providers,
|
||||
|
||||
66
client/src/components/SidePanel/ArtifactsPanel.tsx
Normal file
66
client/src/components/SidePanel/ArtifactsPanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useRef, useEffect, memo } from 'react';
|
||||
import { ResizableHandleAlt, ResizablePanel } from '@librechat/client';
|
||||
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||
|
||||
interface ArtifactsPanelProps {
|
||||
artifacts: React.ReactNode | null;
|
||||
currentLayout: number[];
|
||||
minSizeMain: number;
|
||||
shouldRender: boolean;
|
||||
onRenderChange: (shouldRender: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArtifactsPanel component - memoized to prevent unnecessary re-renders
|
||||
* Only re-renders when artifacts visibility or layout changes
|
||||
*/
|
||||
const ArtifactsPanel = memo(function ArtifactsPanel({
|
||||
artifacts,
|
||||
currentLayout,
|
||||
minSizeMain,
|
||||
shouldRender,
|
||||
onRenderChange,
|
||||
}: ArtifactsPanelProps) {
|
||||
const artifactsPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifacts != null) {
|
||||
onRenderChange(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
artifactsPanelRef.current?.expand();
|
||||
});
|
||||
});
|
||||
} else if (shouldRender) {
|
||||
onRenderChange(false);
|
||||
}
|
||||
}, [artifacts, shouldRender, onRenderChange]);
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{artifacts != null && (
|
||||
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
|
||||
)}
|
||||
<ResizablePanel
|
||||
ref={artifactsPanelRef}
|
||||
defaultSize={artifacts != null ? currentLayout[1] : 0}
|
||||
minSize={minSizeMain}
|
||||
maxSize={70}
|
||||
collapsible={true}
|
||||
collapsedSize={0}
|
||||
order={2}
|
||||
id="artifacts-panel"
|
||||
>
|
||||
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ArtifactsPanel.displayName = 'ArtifactsPanel';
|
||||
|
||||
export default ArtifactsPanel;
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
EToolResources,
|
||||
mergeFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider';
|
||||
import { EToolResources, mergeFileConfig, getEndpointFileConfig } from 'librechat-data-provider';
|
||||
import type { AssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileRow from '~/components/Chat/Input/Files/FileRow';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
@@ -28,11 +24,10 @@ export default function CodeFiles({
|
||||
const { setFilesLoading } = useChatContext();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: endpoint,
|
||||
additionalMetadata: { assistant_id, tool_resource },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
@@ -43,7 +38,11 @@ export default function CodeFiles({
|
||||
}
|
||||
}, [_files]);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint,
|
||||
endpointType: endpoint,
|
||||
});
|
||||
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
|
||||
|
||||
if (isUploadDisabled) {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
mergeFileConfig,
|
||||
retrievalMimeTypes,
|
||||
fileConfig as defaultFileConfig,
|
||||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider';
|
||||
import type { AssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileRow from '~/components/Chat/Input/Files/FileRow';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
@@ -38,11 +38,10 @@ export default function Knowledge({
|
||||
const { setFilesLoading } = useChatContext();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: endpoint,
|
||||
additionalMetadata: { assistant_id },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
@@ -53,7 +52,11 @@ export default function Knowledge({
|
||||
}
|
||||
}, [_files]);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint,
|
||||
endpointType: endpoint,
|
||||
});
|
||||
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
|
||||
|
||||
if (isUploadDisabled) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
mergeFileConfig,
|
||||
megabyte,
|
||||
isAssistantsEndpoint,
|
||||
getEndpointFileConfig,
|
||||
type TFile,
|
||||
} from 'librechat-data-provider';
|
||||
import { useFileMapContext, useChatContext } from '~/Providers';
|
||||
@@ -86,7 +87,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
const fileMap = useFileMapContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { setFiles, conversation } = useChatContext();
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const { addFile } = useUpdateFiles(setFiles);
|
||||
@@ -103,6 +104,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
|
||||
const fileData = fileMap[file.file_id];
|
||||
const endpoint = conversation.endpoint;
|
||||
const endpointType = conversation.endpointType;
|
||||
|
||||
if (!fileData.source) {
|
||||
return;
|
||||
@@ -126,20 +128,31 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
});
|
||||
}
|
||||
|
||||
const { fileSizeLimit, supportedMimeTypes } =
|
||||
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
fileConfig,
|
||||
endpoint,
|
||||
endpointType,
|
||||
});
|
||||
|
||||
if (fileData.bytes > fileSizeLimit) {
|
||||
if (endpointFileConfig.disabled === true) {
|
||||
showToast({
|
||||
message: localize('com_ui_attach_error_disabled'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileData.bytes > (endpointFileConfig.fileSizeLimit ?? Number.MAX_SAFE_INTEGER)) {
|
||||
showToast({
|
||||
message: `${localize('com_ui_attach_error_size')} ${
|
||||
fileSizeLimit / megabyte
|
||||
(endpointFileConfig.fileSizeLimit ?? 0) / megabyte
|
||||
} MB (${endpoint})`,
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultFileConfig.checkType(file.type, supportedMimeTypes)) {
|
||||
if (!defaultFileConfig.checkType(file.type, endpointFileConfig.supportedMimeTypes ?? [])) {
|
||||
showToast({
|
||||
message: `${localize('com_ui_attach_error_type')} ${file.type} (${endpoint})`,
|
||||
status: 'error',
|
||||
@@ -162,7 +175,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
metadata: fileData.metadata,
|
||||
});
|
||||
},
|
||||
[addFile, fileMap, conversation, localize, showToast, fileConfig.endpoints],
|
||||
[addFile, fileMap, conversation, localize, showToast, fileConfig],
|
||||
);
|
||||
|
||||
const filenameFilter = table.getColumn('filename')?.getFilterValue() as string;
|
||||
|
||||
@@ -56,7 +56,7 @@ function DynamicSwitch({
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}:{' '}
|
||||
{defaultValue != null ? 'com_ui_on' : 'com_ui_off'})
|
||||
{defaultValue != null ? localize('com_ui_on') : localize('com_ui_off')})
|
||||
</small>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
excludedKeys,
|
||||
paramSettings,
|
||||
getSettingsKeys,
|
||||
getEndpointField,
|
||||
SettingDefinition,
|
||||
tConvoUpdateSchema,
|
||||
} from 'librechat-data-provider';
|
||||
@@ -12,9 +13,9 @@ import type { TPreset } from 'librechat-data-provider';
|
||||
import { SaveAsPresetDialog } from '~/components/Endpoints';
|
||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField, logger } from '~/utils';
|
||||
import { componentMapping } from './components';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
export default function Parameters() {
|
||||
const localize = useLocalize();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useMemo, memo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
|
||||
import { ResizableHandleAlt, ResizablePanel, useMediaQuery } from '@librechat/client';
|
||||
import type { TEndpointsConfig, TInterfaceConfig } from 'librechat-data-provider';
|
||||
@@ -8,7 +9,7 @@ import { useLocalStorage, useLocalize } from '~/hooks';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import NavToggle from '~/components/Nav/NavToggle';
|
||||
import { useSidePanelContext } from '~/Providers';
|
||||
import { cn, getEndpointField } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import Nav from './Nav';
|
||||
|
||||
const defaultMinSize = 20;
|
||||
@@ -161,6 +162,9 @@ const SidePanel = ({
|
||||
transition: 'width 0.2s ease, visibility 0s linear 0.2s',
|
||||
}}
|
||||
onExpand={() => {
|
||||
if (isCollapsed && (fullCollapse || collapsedSize === 0)) {
|
||||
return;
|
||||
}
|
||||
setIsCollapsed(false);
|
||||
localStorage.setItem('react-resizable-panels:collapsed', 'false');
|
||||
}}
|
||||
|
||||
@@ -2,14 +2,10 @@ import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { getConfigDefaults } from 'librechat-data-provider';
|
||||
import {
|
||||
ResizableHandleAlt,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/client';
|
||||
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import ArtifactsPanel from './ArtifactsPanel';
|
||||
import { normalizeLayout } from '~/utils';
|
||||
import SidePanel from './SidePanel';
|
||||
import store from '~/store';
|
||||
@@ -46,6 +42,7 @@ const SidePanelGroup = memo(
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
|
||||
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
|
||||
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
|
||||
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||||
@@ -109,7 +106,7 @@ const SidePanelGroup = memo(
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes) => throttledSaveLayout(sizes)}
|
||||
className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation"
|
||||
className="relative h-full w-full flex-1 overflow-auto bg-presentation"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={currentLayout[0]}
|
||||
@@ -119,19 +116,17 @@ const SidePanelGroup = memo(
|
||||
>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
{artifacts != null && (
|
||||
<>
|
||||
<ResizableHandleAlt withHandle className="ml-3 bg-border-medium text-text-primary" />
|
||||
<ResizablePanel
|
||||
defaultSize={currentLayout[1]}
|
||||
minSize={minSizeMain}
|
||||
order={2}
|
||||
id="artifacts-panel"
|
||||
>
|
||||
{artifacts}
|
||||
</ResizablePanel>
|
||||
</>
|
||||
|
||||
{!isSmallScreen && (
|
||||
<ArtifactsPanel
|
||||
artifacts={artifacts}
|
||||
currentLayout={currentLayout}
|
||||
minSizeMain={minSizeMain}
|
||||
shouldRender={shouldRenderArtifacts}
|
||||
onRenderChange={setShouldRenderArtifacts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideSidePanel && interfaceConfig.sidePanel === true && (
|
||||
<SidePanel
|
||||
panelRef={panelRef}
|
||||
@@ -143,12 +138,15 @@ const SidePanelGroup = memo(
|
||||
setCollapsedSize={setCollapsedSize}
|
||||
fullCollapse={fullCollapse}
|
||||
setFullCollapse={setFullCollapse}
|
||||
defaultSize={currentLayout[currentLayout.length - 1]}
|
||||
hasArtifacts={artifacts != null}
|
||||
interfaceConfig={interfaceConfig}
|
||||
hasArtifacts={shouldRenderArtifacts}
|
||||
defaultSize={currentLayout[currentLayout.length - 1]}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
{artifacts != null && isSmallScreen && (
|
||||
<div className="fixed inset-0 z-[100]">{artifacts}</div>
|
||||
)}
|
||||
<button
|
||||
aria-label="Close right side panel"
|
||||
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dataService, QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type { UseMutationResult, UseMutationOptions } from '@tanstack/react-query';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
|
||||
type EditArtifactContext = {
|
||||
previousMessages: Record<string, t.TMessage[] | undefined>;
|
||||
updatedConversationId: string | null;
|
||||
};
|
||||
|
||||
export const useEditArtifact = (
|
||||
_options?: t.EditArtifactOptions,
|
||||
): UseMutationResult<t.TEditArtifactResponse, Error, t.TEditArtifactRequest> => {
|
||||
): UseMutationResult<
|
||||
t.TEditArtifactResponse,
|
||||
Error,
|
||||
t.TEditArtifactRequest,
|
||||
EditArtifactContext
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onSuccess, ...options } = _options ?? {};
|
||||
return useMutation({
|
||||
const { onSuccess, onError, onMutate: userOnMutate, ...options } = _options ?? {};
|
||||
|
||||
const mutationOptions: UseMutationOptions<
|
||||
t.TEditArtifactResponse,
|
||||
Error,
|
||||
t.TEditArtifactRequest,
|
||||
EditArtifactContext
|
||||
> = {
|
||||
mutationFn: (variables: t.TEditArtifactRequest) => dataService.editArtifact(variables),
|
||||
/**
|
||||
* onMutate: No optimistic updates for artifact editing
|
||||
* The code editor shows changes instantly via local Sandpack state
|
||||
* Optimistic updates cause "original content not found" errors because:
|
||||
* 1. First edit optimistically updates cache
|
||||
* 2. Artifact.content reflects the updated cache
|
||||
* 3. Next edit sends updated content as "original" → doesn't match DB → error
|
||||
*/
|
||||
onMutate: async (vars) => {
|
||||
// Call user's onMutate if provided
|
||||
if (userOnMutate) {
|
||||
await userOnMutate(vars);
|
||||
}
|
||||
return { previousMessages: {}, updatedConversationId: null };
|
||||
},
|
||||
onError: (error, vars, context) => {
|
||||
onError?.(error, vars, context);
|
||||
},
|
||||
/**
|
||||
* On success: Update with server response to ensure consistency
|
||||
*/
|
||||
onSuccess: (data, vars, context) => {
|
||||
let targetNotFound = true;
|
||||
const setMessageData = (conversationId?: string | null) => {
|
||||
@@ -50,11 +87,13 @@ export const useEditArtifact = (
|
||||
console.warn(
|
||||
'Edited Artifact Message not found in cache, trying `new` as `conversationId`',
|
||||
);
|
||||
setMessageData(Constants.NEW_CONVO);
|
||||
setMessageData(Constants.NEW_CONVO as string);
|
||||
}
|
||||
|
||||
onSuccess?.(data, vars, context);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
693
client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts
Normal file
693
client/src/hooks/Artifacts/__tests__/useArtifacts.test.ts
Normal file
@@ -0,0 +1,693 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { Artifact } from '~/common';
|
||||
|
||||
/** Mock dependencies */
|
||||
jest.mock('~/Providers', () => ({
|
||||
useArtifactsContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
logger: {
|
||||
log: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
/** Mock store before importing */
|
||||
jest.mock('~/store', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
artifactsState: { key: 'artifactsState' },
|
||||
currentArtifactId: { key: 'currentArtifactId' },
|
||||
artifactsVisibility: { key: 'artifactsVisibility' },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('recoil', () => {
|
||||
const actualRecoil = jest.requireActual('recoil');
|
||||
return {
|
||||
...actualRecoil,
|
||||
useRecoilValue: jest.fn(),
|
||||
useRecoilState: jest.fn(),
|
||||
useResetRecoilState: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
/** Import mocked functions after mocking */
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { useRecoilValue, useRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { logger } from '~/utils';
|
||||
import useArtifacts from '../useArtifacts';
|
||||
|
||||
describe('useArtifacts', () => {
|
||||
const mockResetArtifacts = jest.fn();
|
||||
const mockResetCurrentArtifactId = jest.fn();
|
||||
const mockSetCurrentArtifactId = jest.fn();
|
||||
|
||||
const createArtifact = (partial: Partial<Artifact>): Artifact => ({
|
||||
id: 'artifact-1',
|
||||
title: 'Test Artifact',
|
||||
type: 'application/vnd.react',
|
||||
content: 'const App = () => <div>Test</div>',
|
||||
messageId: 'msg-1',
|
||||
lastUpdateTime: Date.now(),
|
||||
...partial,
|
||||
});
|
||||
|
||||
const defaultContext = {
|
||||
isSubmitting: false,
|
||||
latestMessageId: 'msg-1',
|
||||
latestMessageText: '',
|
||||
conversationId: 'conv-1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue(defaultContext);
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
(useResetRecoilState as jest.Mock).mockImplementation((atom) => {
|
||||
if (atom?.key === 'artifactsState') {
|
||||
return mockResetArtifacts;
|
||||
}
|
||||
if (atom?.key === 'currentArtifactId') {
|
||||
return mockResetCurrentArtifactId;
|
||||
}
|
||||
return jest.fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with preview tab active', () => {
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should return null currentArtifact when no artifacts exist', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
expect(result.current.currentArtifact).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty orderedArtifactIds when no artifacts exist', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifact ordering', () => {
|
||||
it('should order artifacts by lastUpdateTime', () => {
|
||||
const artifacts = {
|
||||
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
||||
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.orderedArtifactIds).toEqual(['artifact-1', 'artifact-2', 'artifact-3']);
|
||||
});
|
||||
|
||||
it('should automatically select latest artifact', () => {
|
||||
const artifacts = {
|
||||
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab switching - enclosed artifacts', () => {
|
||||
it('should switch to preview when enclosed artifact is detected during generation', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: false,
|
||||
latestMessageText: '',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Generation starts with enclosed artifact */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>\n:::',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Should switch to preview when enclosed detected */
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should not switch to preview if artifact is not enclosed', () => {
|
||||
const artifact = createArtifact({
|
||||
content: 'const App = () => <div>Test</div>',
|
||||
});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Update with non-enclosed artifact */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\nconst App = () => <div>Test</div>',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Should switch to code since artifact content is in message and not enclosed */
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
expect(logger.log).not.toHaveBeenCalledWith(
|
||||
'artifacts',
|
||||
expect.stringContaining('Enclosed artifact'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should only switch to preview once per artifact', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\ncode\n:::',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
const firstCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
||||
call[1]?.includes('Enclosed artifact'),
|
||||
).length;
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}\ncode\n:::\nMore text',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
const secondCallCount = (logger.log as jest.Mock).mock.calls.filter((call) =>
|
||||
call[1]?.includes('Enclosed artifact'),
|
||||
).length;
|
||||
|
||||
expect(secondCallCount).toBe(firstCallCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab switching - non-enclosed artifacts', () => {
|
||||
it('should switch to code when non-enclosed artifact content appears', () => {
|
||||
const artifact = createArtifact({
|
||||
content: 'const App = () => <div>Test Component</div>',
|
||||
});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Here is the code: const App = () => <div>Test Component</div>',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
});
|
||||
|
||||
it('should not switch to code if artifact content is not in message text', () => {
|
||||
const artifact = createArtifact({
|
||||
content: 'const App = () => <div>Test</div>',
|
||||
});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Some other text here',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation changes', () => {
|
||||
it('should reset artifacts when conversation changes', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: 'conv-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset artifacts when navigating to new conversation from another conversation', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
/** Start with existing conversation (NOT Constants.NEW_CONVO) */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: 'existing-conv',
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
/** Navigate to NEW_CONVO - this should trigger the else if branch */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not reset artifacts on initial render', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
expect(mockResetArtifacts).not.toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset when transitioning from null to NEW_CONVO', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
/** Start with null conversationId */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
/** Transition to NEW_CONVO - triggers the else if branch (line 44) */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Should reset because we're now on NEW_CONVO */
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset state flags when message ID changes', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||
latestMessageId: 'msg-1',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
// First artifact becomes enclosed
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
|
||||
// New message starts
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'New message',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
// Should allow switching again for the new message
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\nnew code\n:::',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup on unmount', () => {
|
||||
it('should reset artifacts when unmounting', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { unmount } = renderHook(() => useArtifacts());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockResetArtifacts).toHaveBeenCalled();
|
||||
expect(mockResetCurrentArtifactId).toHaveBeenCalled();
|
||||
expect(logger.log).toHaveBeenCalledWith('artifacts_visibility', 'Unmounting artifacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual tab switching', () => {
|
||||
it('should allow manually switching tabs', () => {
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab('code');
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
});
|
||||
|
||||
it('should allow switching back to preview after manual switch to code', () => {
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab('code');
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe('code');
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab('preview');
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentIndex calculation', () => {
|
||||
it('should return correct index for current artifact', () => {
|
||||
const artifacts = {
|
||||
'artifact-1': createArtifact({ id: 'artifact-1', lastUpdateTime: 1000 }),
|
||||
'artifact-2': createArtifact({ id: 'artifact-2', lastUpdateTime: 2000 }),
|
||||
'artifact-3': createArtifact({ id: 'artifact-3', lastUpdateTime: 3000 }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-2', mockSetCurrentArtifactId]);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should return -1 for non-existent artifact', () => {
|
||||
const artifacts = {
|
||||
'artifact-1': createArtifact({ id: 'artifact-1' }),
|
||||
};
|
||||
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(artifacts);
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['non-existent', mockSetCurrentArtifactId]);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.currentIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should detect and handle enclosed artifacts during generation', async () => {
|
||||
/** Start fresh with enclosed artifact already present */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({});
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Component"}\nconst App = () => <div>Test</div>\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Should detect enclosed pattern and be on preview */
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts in sequence', () => {
|
||||
const artifact1 = createArtifact({ id: 'artifact-1', messageId: 'msg-1' });
|
||||
const artifact2 = createArtifact({ id: 'artifact-2', messageId: 'msg-2' });
|
||||
|
||||
/** First artifact */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact1 });
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode1\n:::',
|
||||
latestMessageId: 'msg-1',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
|
||||
/** Second artifact starts (new message) */
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({
|
||||
'artifact-1': artifact1,
|
||||
'artifact-2': artifact2,
|
||||
});
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Here is another one',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
/** Second artifact becomes enclosed */
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode2\n:::',
|
||||
latestMessageId: 'msg-2',
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null artifacts gracefully', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||
expect(result.current.currentArtifact).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined artifacts gracefully', () => {
|
||||
(useRecoilValue as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.orderedArtifactIds).toEqual([]);
|
||||
expect(result.current.currentArtifact).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty latestMessageText', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: '',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should handle malformed artifact syntax', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact\ncode but no closing',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should handle artifact with only opening tag', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"}',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifact content comparison', () => {
|
||||
it('should not switch tabs when artifact content does not change', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: 'Some text',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useArtifacts());
|
||||
|
||||
const initialTab = result.current.activeTab;
|
||||
|
||||
/** Same content, just rerender */
|
||||
rerender();
|
||||
|
||||
expect(result.current.activeTab).toBe(initialTab);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSubmitting state handling', () => {
|
||||
it('should process when isSubmitting is true', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue(['artifact-1', mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||
});
|
||||
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
expect(mockSetCurrentArtifactId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still select latest artifact even when idle (via orderedArtifactIds effect)', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: false,
|
||||
latestMessageText: 'Some text',
|
||||
});
|
||||
|
||||
renderHook(() => useArtifacts());
|
||||
|
||||
/** The orderedArtifactIds effect always runs when artifacts change */
|
||||
expect(mockSetCurrentArtifactId).toHaveBeenCalledWith('artifact-1');
|
||||
});
|
||||
|
||||
it('should not process when latestMessageId is null', () => {
|
||||
const artifact = createArtifact({});
|
||||
(useRecoilValue as jest.Mock).mockReturnValue({ 'artifact-1': artifact });
|
||||
(useRecoilState as jest.Mock).mockReturnValue([null, mockSetCurrentArtifactId]);
|
||||
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageId: null,
|
||||
latestMessageText: ':::artifact{}\ncode\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
/** Main effect should exit early and not switch tabs */
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex pattern matching', () => {
|
||||
it('should match artifact with title attribute', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="My Component"}\ncode\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should match artifact with multiple attributes', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test" type="react" identifier="comp-1"}\ncode\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should match artifact with code blocks inside', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{}\n```typescript\nconst x = 1;\n```\n:::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
|
||||
it('should match artifact with whitespace variations', () => {
|
||||
(useArtifactsContext as jest.Mock).mockReturnValue({
|
||||
...defaultContext,
|
||||
isSubmitting: true,
|
||||
latestMessageText: ':::artifact{title="Test"} \n\n code here \n\n :::',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtifacts());
|
||||
|
||||
expect(result.current.activeTab).toBe('preview');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { logger } from '~/utils';
|
||||
import { useArtifactsContext } from '~/Providers';
|
||||
import { getKey } from '~/utils/artifacts';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useArtifacts() {
|
||||
@@ -22,6 +21,7 @@ export default function useArtifacts() {
|
||||
);
|
||||
}, [artifacts]);
|
||||
|
||||
const prevIsSubmittingRef = useRef<boolean>(false);
|
||||
const lastContentRef = useRef<string | null>(null);
|
||||
const hasEnclosedArtifactRef = useRef<boolean>(false);
|
||||
const hasAutoSwitchedToCodeRef = useRef<boolean>(false);
|
||||
@@ -36,6 +36,7 @@ export default function useArtifacts() {
|
||||
lastRunMessageIdRef.current = null;
|
||||
lastContentRef.current = null;
|
||||
hasEnclosedArtifactRef.current = false;
|
||||
hasAutoSwitchedToCodeRef.current = false;
|
||||
};
|
||||
if (conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null) {
|
||||
resetState();
|
||||
@@ -57,8 +58,17 @@ export default function useArtifacts() {
|
||||
}
|
||||
}, [setCurrentArtifactId, orderedArtifactIds]);
|
||||
|
||||
/**
|
||||
* Manage artifact selection and code tab switching for non-enclosed artifacts
|
||||
* Runs when artifact content changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isSubmitting) {
|
||||
// Check if we just finished submitting (transition from true to false)
|
||||
const justFinishedSubmitting = prevIsSubmittingRef.current && !isSubmitting;
|
||||
prevIsSubmittingRef.current = isSubmitting;
|
||||
|
||||
// Only process during submission OR when just finished
|
||||
if (!isSubmitting && !justFinishedSubmitting) {
|
||||
return;
|
||||
}
|
||||
if (orderedArtifactIds.length === 0) {
|
||||
@@ -69,23 +79,15 @@ export default function useArtifacts() {
|
||||
}
|
||||
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
|
||||
const latestArtifact = artifacts?.[latestArtifactId];
|
||||
if (latestArtifact?.content === lastContentRef.current) {
|
||||
if (latestArtifact?.content === lastContentRef.current && !justFinishedSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentArtifactId(latestArtifactId);
|
||||
lastContentRef.current = latestArtifact?.content ?? null;
|
||||
|
||||
const hasEnclosedArtifact =
|
||||
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
|
||||
latestMessageText.trim(),
|
||||
);
|
||||
|
||||
if (hasEnclosedArtifact && !hasEnclosedArtifactRef.current) {
|
||||
setActiveTab('preview');
|
||||
hasEnclosedArtifactRef.current = true;
|
||||
hasAutoSwitchedToCodeRef.current = false;
|
||||
} else if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
|
||||
// Only switch to code tab if we haven't detected an enclosed artifact yet
|
||||
if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
|
||||
const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? '';
|
||||
if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) {
|
||||
setActiveTab('code');
|
||||
@@ -101,6 +103,28 @@ export default function useArtifacts() {
|
||||
setCurrentArtifactId,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Watch for enclosed artifact pattern during message generation
|
||||
* Optimized: Exits early if already detected, only checks during streaming
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isSubmitting || hasEnclosedArtifactRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEnclosedArtifact =
|
||||
/:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test(
|
||||
latestMessageText.trim(),
|
||||
);
|
||||
|
||||
if (hasEnclosedArtifact) {
|
||||
logger.log('artifacts', 'Enclosed artifact detected during generation, switching to preview');
|
||||
setActiveTab('preview');
|
||||
hasEnclosedArtifactRef.current = true;
|
||||
hasAutoSwitchedToCodeRef.current = false;
|
||||
}
|
||||
}, [isSubmitting, latestMessageText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestMessageId !== lastRunMessageIdRef.current) {
|
||||
lastRunMessageIdRef.current = latestMessageId;
|
||||
@@ -112,22 +136,13 @@ export default function useArtifacts() {
|
||||
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
|
||||
|
||||
const currentIndex = orderedArtifactIds.indexOf(currentArtifactId ?? '');
|
||||
const cycleArtifact = (direction: 'next' | 'prev') => {
|
||||
let newIndex: number;
|
||||
if (direction === 'next') {
|
||||
newIndex = (currentIndex + 1) % orderedArtifactIds.length;
|
||||
} else {
|
||||
newIndex = (currentIndex - 1 + orderedArtifactIds.length) % orderedArtifactIds.length;
|
||||
}
|
||||
setCurrentArtifactId(orderedArtifactIds[newIndex]);
|
||||
};
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
setCurrentArtifactId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
QueryKeys,
|
||||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
getEndpointField,
|
||||
isAgentsEndpoint,
|
||||
parseCompactConvo,
|
||||
replaceSpecialVars,
|
||||
@@ -25,10 +26,10 @@ import type { TAskFunction, ExtendedFile } from '~/common';
|
||||
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
|
||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||
import store, { useGetEphemeralAgent } from '~/store';
|
||||
import { getEndpointField, logger } from '~/utils';
|
||||
import useUserKey from '~/hooks/Input/useUserKey';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
const logChatRequest = (request: Record<string, unknown>) => {
|
||||
logger.log('=====================================\nAsk function called with:');
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import { LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import { getEndpointField, LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type {
|
||||
TPreset,
|
||||
TModelsConfig,
|
||||
TConversation,
|
||||
TEndpointsConfig,
|
||||
EModelEndpoint,
|
||||
TModelsConfig,
|
||||
TConversation,
|
||||
TPreset,
|
||||
} from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { AssistantListItem } from '~/common';
|
||||
import { getEndpointField, buildDefaultConvo, getDefaultEndpoint, logger } from '~/utils';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import useAssistantListMap from '~/hooks/Assistants/useAssistantListMap';
|
||||
import { buildDefaultConvo, getDefaultEndpoint, logger } from '~/utils';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { mainTextareaId } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -2,20 +2,14 @@ import { useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, Constants, dataService } from 'librechat-data-provider';
|
||||
import { QueryKeys, Constants, dataService, getEndpointField } from 'librechat-data-provider';
|
||||
import type {
|
||||
TEndpointsConfig,
|
||||
TStartupConfig,
|
||||
TModelsConfig,
|
||||
TConversation,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
getDefaultEndpoint,
|
||||
clearMessagesCache,
|
||||
buildDefaultConvo,
|
||||
getEndpointField,
|
||||
logger,
|
||||
} from '~/utils';
|
||||
import { getDefaultEndpoint, clearMessagesCache, buildDefaultConvo, logger } from '~/utils';
|
||||
import { useApplyModelSpecEffects } from '~/hooks/Agents';
|
||||
import store from '~/store';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const knownEndpointAssets = {
|
||||
[KnownEndpoints.fireworks]: 'assets/fireworks.png',
|
||||
[KnownEndpoints.google]: 'assets/google.svg',
|
||||
[KnownEndpoints.groq]: 'assets/groq.png',
|
||||
[KnownEndpoints.helicone]: 'assets/helicone.png',
|
||||
[KnownEndpoints.huggingface]: 'assets/huggingface.svg',
|
||||
[KnownEndpoints.mistral]: 'assets/mistral.png',
|
||||
[KnownEndpoints.mlx]: 'assets/mlx.png',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
alternateName,
|
||||
EModelEndpoint,
|
||||
PermissionTypes,
|
||||
getEndpointField,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
TEndpointsConfig,
|
||||
@@ -14,8 +15,8 @@ import type {
|
||||
Agent,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Endpoint } from '~/common';
|
||||
import { mapEndpoints, getIconKey, getEndpointField } from '~/utils';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { mapEndpoints, getIconKey } from '~/utils';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { icons } from './Icons';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { NativeTypes } from 'react-dnd-html5-backend';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
@@ -7,10 +8,12 @@ import {
|
||||
Tools,
|
||||
QueryKeys,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
AgentCapabilities,
|
||||
isAssistantsEndpoint,
|
||||
getEndpointFileConfig,
|
||||
defaultAgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import type { DropTargetMonitor } from 'react-dnd';
|
||||
@@ -18,9 +21,12 @@ import type * as t from 'librechat-data-provider';
|
||||
import store, { ephemeralAgentByConvoId } from '~/store';
|
||||
import useFileHandling from './useFileHandling';
|
||||
import { isEphemeralAgent } from '~/common';
|
||||
import useLocalize from '../useLocalize';
|
||||
|
||||
export default function useDragHelpers() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [draggedFiles, setDraggedFiles] = useState<File[]>([]);
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
|
||||
@@ -33,9 +39,7 @@ export default function useDragHelpers() {
|
||||
[conversation?.endpoint],
|
||||
);
|
||||
|
||||
const { handleFiles } = useFileHandling({
|
||||
overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents,
|
||||
});
|
||||
const { handleFiles } = useFileHandling();
|
||||
|
||||
const handleOptionSelect = useCallback(
|
||||
(toolResource: EToolResources | undefined) => {
|
||||
@@ -62,6 +66,26 @@ export default function useDragHelpers() {
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(item: { files: File[] }) => {
|
||||
/** Early block: leverage endpoint file config to prevent drag/drop on disabled endpoints */
|
||||
const currentEndpoint = conversationRef.current?.endpoint ?? 'default';
|
||||
const currentEndpointType = conversationRef.current?.endpointType ?? undefined;
|
||||
const cfg = queryClient.getQueryData<t.FileConfig>([QueryKeys.fileConfig]);
|
||||
if (cfg) {
|
||||
const mergedCfg = mergeFileConfig(cfg);
|
||||
const endpointCfg = getEndpointFileConfig({
|
||||
fileConfig: mergedCfg,
|
||||
endpoint: currentEndpoint,
|
||||
endpointType: currentEndpointType,
|
||||
});
|
||||
if (endpointCfg?.disabled === true) {
|
||||
showToast({
|
||||
message: localize('com_ui_attach_error_disabled'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAssistants) {
|
||||
handleFilesRef.current(item.files);
|
||||
return;
|
||||
@@ -110,7 +134,7 @@ export default function useDragHelpers() {
|
||||
setDraggedFiles(item.files);
|
||||
setShowModal(true);
|
||||
},
|
||||
[isAssistants, queryClient],
|
||||
[isAssistants, queryClient, showToast, localize],
|
||||
);
|
||||
|
||||
const [{ canDrop, isOver }, drop] = useDrop(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
@@ -6,16 +6,14 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
QueryKeys,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
isAssistantsEndpoint,
|
||||
getEndpointFileConfig,
|
||||
defaultAssistantsVersion,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { EndpointFileConfig, TEndpointsConfig, TError } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
||||
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
||||
@@ -29,9 +27,7 @@ import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
type UseFileHandling = {
|
||||
fileSetter?: FileSetter;
|
||||
overrideEndpoint?: EModelEndpoint;
|
||||
fileFilter?: (file: File) => boolean;
|
||||
overrideEndpointFileConfig?: EndpointFileConfig;
|
||||
additionalMetadata?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
@@ -54,17 +50,13 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
|
||||
const agent_id = params?.additionalMetadata?.agent_id ?? '';
|
||||
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
|
||||
const endpointType = useMemo(() => conversation?.endpointType, [conversation?.endpointType]);
|
||||
const endpoint = useMemo(() => conversation?.endpoint ?? 'default', [conversation?.endpoint]);
|
||||
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const endpoint = useMemo(
|
||||
() =>
|
||||
params?.overrideEndpoint ?? conversation?.endpointType ?? conversation?.endpoint ?? 'default',
|
||||
[params?.overrideEndpoint, conversation?.endpointType, conversation?.endpoint],
|
||||
);
|
||||
|
||||
const displayToast = useCallback(() => {
|
||||
if (errors.length > 1) {
|
||||
// TODO: this should not be a dynamic localize input!!
|
||||
@@ -169,10 +161,7 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('endpoint', endpoint);
|
||||
formData.append(
|
||||
'original_endpoint',
|
||||
conversation?.endpointType || conversation?.endpoint || '',
|
||||
);
|
||||
formData.append('endpointType', endpointType ?? '');
|
||||
formData.append('file', extendedFile.file as File, encodeURIComponent(filename));
|
||||
formData.append('file_id', extendedFile.file_id);
|
||||
|
||||
@@ -194,7 +183,7 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isAgentsEndpoint(endpoint)) {
|
||||
if (!isAssistantsEndpoint(endpointType ?? endpoint)) {
|
||||
if (!agent_id) {
|
||||
formData.append('message_file', 'true');
|
||||
}
|
||||
@@ -205,9 +194,7 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
if (conversation?.agent_id != null && formData.get('agent_id') == null) {
|
||||
formData.append('agent_id', conversation.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAssistantsEndpoint(endpoint)) {
|
||||
uploadFile.mutate(formData);
|
||||
return;
|
||||
}
|
||||
@@ -264,18 +251,19 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||
/* Validate files */
|
||||
let filesAreValid: boolean;
|
||||
try {
|
||||
const endpointFileConfig = getEndpointFileConfig({
|
||||
endpoint,
|
||||
fileConfig,
|
||||
endpointType,
|
||||
});
|
||||
|
||||
filesAreValid = validateFiles({
|
||||
files,
|
||||
fileList,
|
||||
setError,
|
||||
endpointFileConfig:
|
||||
params?.overrideEndpointFileConfig ??
|
||||
fileConfig?.endpoints?.[endpoint] ??
|
||||
fileConfig?.endpoints?.default ??
|
||||
defaultFileConfig.endpoints[endpoint] ??
|
||||
defaultFileConfig.endpoints.default,
|
||||
fileConfig,
|
||||
endpointFileConfig,
|
||||
toolResource: _toolResource,
|
||||
fileConfig: fileConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('file validation error', error);
|
||||
|
||||
@@ -5,11 +5,9 @@ import type { SharePointFile } from '~/data-provider/Files/sharepoint';
|
||||
|
||||
interface UseSharePointFileHandlingProps {
|
||||
fileSetter?: any;
|
||||
toolResource?: string;
|
||||
fileFilter?: (file: File) => boolean;
|
||||
additionalMetadata?: Record<string, string | undefined>;
|
||||
overrideEndpoint?: any;
|
||||
overrideEndpointFileConfig?: any;
|
||||
toolResource?: string;
|
||||
}
|
||||
|
||||
interface UseSharePointFileHandlingReturn {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField } from '~/utils';
|
||||
import useUserKey from './useUserKey';
|
||||
|
||||
export default function useRequiresKey() {
|
||||
|
||||
@@ -99,6 +99,12 @@ export default function useStepHandler({
|
||||
if (!updatedContent[index]) {
|
||||
updatedContent[index] = { type: contentPart.type as AllContentTypes };
|
||||
}
|
||||
/** Prevent overwriting an existing content part with a different type */
|
||||
const existingType = (updatedContent[index]?.type as string | undefined) ?? '';
|
||||
if (existingType && !contentType.startsWith(existingType)) {
|
||||
console.warn('Content type mismatch');
|
||||
return message;
|
||||
}
|
||||
|
||||
if (
|
||||
contentType.startsWith(ContentTypes.TEXT) &&
|
||||
@@ -151,12 +157,16 @@ export default function useStepHandler({
|
||||
const existingToolCall = existingContent?.tool_call;
|
||||
const toolCallArgs = (contentPart.tool_call as Agents.ToolCall).args;
|
||||
/** When args are a valid object, they are likely already invoked */
|
||||
const args =
|
||||
let args =
|
||||
finalUpdate ||
|
||||
typeof existingToolCall?.args === 'object' ||
|
||||
typeof toolCallArgs === 'object'
|
||||
? contentPart.tool_call.args
|
||||
: (existingToolCall?.args ?? '') + (toolCallArgs ?? '');
|
||||
/** Preserve previously streamed args when final update omits them */
|
||||
if (finalUpdate && args == null && existingToolCall?.args != null) {
|
||||
args = existingToolCall.args;
|
||||
}
|
||||
|
||||
const id = getNonEmptyValue([contentPart.tool_call.id, existingToolCall?.id]) ?? '';
|
||||
const name = getNonEmptyValue([contentPart.tool_call.name, existingToolCall?.name]) ?? '';
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil';
|
||||
import {
|
||||
Constants,
|
||||
FileSources,
|
||||
EModelEndpoint,
|
||||
isParamEndpoint,
|
||||
getEndpointField,
|
||||
LocalStorageKeys,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil';
|
||||
import type {
|
||||
TPreset,
|
||||
TSubmission,
|
||||
@@ -19,19 +20,18 @@ import type {
|
||||
} from 'librechat-data-provider';
|
||||
import type { AssistantListItem } from '~/common';
|
||||
import {
|
||||
getEndpointField,
|
||||
buildDefaultConvo,
|
||||
updateLastSelectedModel,
|
||||
getDefaultModelSpec,
|
||||
getDefaultEndpoint,
|
||||
getModelSpecPreset,
|
||||
getDefaultModelSpec,
|
||||
updateLastSelectedModel,
|
||||
buildDefaultConvo,
|
||||
logger,
|
||||
} from '~/utils';
|
||||
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import useAssistantListMap from './Assistants/useAssistantListMap';
|
||||
import { useResetChatBadges } from './useChatBadges';
|
||||
import { useApplyModelSpecEffects } from './Agents';
|
||||
import { usePauseGlobalAudio } from './Audio';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const useNewConvo = (index = 0) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"chat_direction_left_to_right": "Leer – etwas fehlt noch",
|
||||
"chat_direction_right_to_left": "Leer – etwas fehlt noch",
|
||||
"chat_direction_left_to_right": "Links nach Rechts",
|
||||
"chat_direction_right_to_left": "Rechts nach Links",
|
||||
"com_a11y_ai_composing": "Die KI erstellt noch ihre Antwort.\n",
|
||||
"com_a11y_end": "Die KI hat die Antwort fertiggestellt.",
|
||||
"com_a11y_start": "Die KI hat mit ihrer Antwort begonnen. ",
|
||||
@@ -365,6 +365,7 @@
|
||||
"com_error_files_process": "Bei der Verarbeitung der Datei ist ein Fehler aufgetreten.",
|
||||
"com_error_files_upload": "Beim Hochladen der Datei ist ein Fehler aufgetreten",
|
||||
"com_error_files_upload_canceled": "Die Datei-Upload-Anfrage wurde abgebrochen. Hinweis: Der Upload-Vorgang könnte noch im Hintergrund laufen und die Datei muss möglicherweise manuell gelöscht werden.",
|
||||
"com_error_files_upload_too_large": "Die Datei ist zu groß. Bitte lade eine Datei hoch, die kleiner als {{0}} MB ist.",
|
||||
"com_error_files_validation": "Bei der Validierung der Datei ist ein Fehler aufgetreten.",
|
||||
"com_error_google_tool_conflict": "Die integrierten Google-Tools können nicht zusammen mit externen Tools verwendet werden. Bitte deaktiviere entweder die integrierten oder die externen Tools.",
|
||||
"com_error_heic_conversion": "Das HEIC-Bild konnte nicht in JPEG konvertiert werden. Bitte versuchen Sie, das Bild manuell zu konvertieren oder verwenden Sie ein anderes Format.",
|
||||
@@ -385,6 +386,7 @@
|
||||
"com_files_download_progress": "{{0}} von {{1}} Dateien",
|
||||
"com_files_downloading": "Dateien werden heruntergeladen",
|
||||
"com_files_filter": "Dateien filtern...",
|
||||
"com_files_filter_by": "Dateien filtern nach...",
|
||||
"com_files_no_results": "Keine Ergebnisse.",
|
||||
"com_files_number_selected": "{{0}} von {{1}} Datei(en) ausgewählt",
|
||||
"com_files_preparing_download": "Download wird vorbereitet...",
|
||||
@@ -558,6 +560,7 @@
|
||||
"com_nav_setting_balance": "Saldo",
|
||||
"com_nav_setting_chat": "Chat",
|
||||
"com_nav_setting_data": "Datensteuerung",
|
||||
"com_nav_setting_delay": "Verzögerung (s)",
|
||||
"com_nav_setting_general": "Allgemein",
|
||||
"com_nav_setting_mcp": "MCP Einstellungen",
|
||||
"com_nav_setting_personalization": "Personalisierung",
|
||||
@@ -628,6 +631,7 @@
|
||||
"com_ui_action_button": "Aktions Button",
|
||||
"com_ui_active": "Aktiv",
|
||||
"com_ui_add": "Hinzufügen",
|
||||
"com_ui_add_api_key": "API-Schlüssel hinzufügen",
|
||||
"com_ui_add_mcp": "MCP hinzufügen",
|
||||
"com_ui_add_mcp_server": "MCP Server hinzufügen",
|
||||
"com_ui_add_model_preset": "Ein KI-Modell oder eine Voreinstellung für eine zusätzliche Antwort hinzufügen",
|
||||
@@ -654,6 +658,16 @@
|
||||
"com_ui_agent_deleted": "Agent erfolgreich gelöscht",
|
||||
"com_ui_agent_duplicate_error": "Beim Duplizieren des Assistenten ist ein Fehler aufgetreten",
|
||||
"com_ui_agent_duplicated": "Agent wurde erfolgreich dupliziert",
|
||||
"com_ui_agent_handoff_add": "Übergabe-Agent hinzufügen",
|
||||
"com_ui_agent_handoff_description": "Übergabebeschreibung",
|
||||
"com_ui_agent_handoff_description_placeholder": "z. B. Übergabe an Marketing-Texter für Blogartikel-Entwurf",
|
||||
"com_ui_agent_handoff_info": "Konfiguriere Agenten, an die dieser Agent Konversationen übergeben kann, wenn spezifisches Fachwissen benötigt wird.",
|
||||
"com_ui_agent_handoff_info_2": "Jede Übergabe erstellt ein Transfer-Tool, das eine nahtlose Weiterleitung an spezialisierte Agenten mit Kontext ermöglicht.",
|
||||
"com_ui_agent_handoff_max": "Maximale Anzahl ({{0}}) an Übergabe-Agenten erreicht.",
|
||||
"com_ui_agent_handoff_prompt": "Inhalt zur Übergabe",
|
||||
"com_ui_agent_handoff_prompt_key": "Parametername für Inhalt (Standard: 'instructions') ",
|
||||
"com_ui_agent_handoff_prompt_placeholder": "Sag diesem Agenten, welchen Inhalt er generieren und an den Übergabe-Agenten weiterleiten soll. Du musst hier etwas eingeben, um diese Funktion zu aktivieren.",
|
||||
"com_ui_agent_handoffs": "Agenten-Übergaben",
|
||||
"com_ui_agent_name_is_required": "Ein Agentenname ist erforderlich.",
|
||||
"com_ui_agent_recursion_limit": "Maximale Agenten-Schritte",
|
||||
"com_ui_agent_recursion_limit_info": "Begrenzt, wie viele Schritte der Agent in einem Durchlauf ausführen kann, bevor er eine endgültige Antwort gibt. Der Standardwert ist 25 Schritte. Ein Schritt ist entweder eine KI-API-Anfrage oder eine Werkzeugnutzungsrunde. Eine einfache Werkzeuginteraktion umfasst beispielsweise 3 Schritte: die ursprüngliche Anfrage, die Werkzeugnutzung und die Folgeanfrage.",
|
||||
@@ -725,6 +739,7 @@
|
||||
"com_ui_basic": "Basic",
|
||||
"com_ui_basic_auth_header": "Basic-Authentifizierungsheader",
|
||||
"com_ui_bearer": "Bearer",
|
||||
"com_ui_beta": "Beta",
|
||||
"com_ui_bookmark_delete_confirm": "Bist du sicher, dass du dieses Lesezeichen löschen möchtest?",
|
||||
"com_ui_bookmarks": "Lesezeichen",
|
||||
"com_ui_bookmarks_add": "Lesezeichen hinzufügen",
|
||||
@@ -757,6 +772,7 @@
|
||||
"com_ui_client_secret": "Client Secret",
|
||||
"com_ui_close": "Schließen",
|
||||
"com_ui_close_menu": "Menü schließen",
|
||||
"com_ui_close_settings": "Einstellungen schließen",
|
||||
"com_ui_close_window": "Fenster schliessen",
|
||||
"com_ui_code": "Code",
|
||||
"com_ui_collapse_chat": "Chat einklappen",
|
||||
@@ -779,6 +795,8 @@
|
||||
"com_ui_copied_to_clipboard": "In die Zwischenablage kopiert",
|
||||
"com_ui_copy_code": "Code kopieren",
|
||||
"com_ui_copy_link": "Link kopieren",
|
||||
"com_ui_copy_stack_trace": "Stack-Trace kopieren",
|
||||
"com_ui_copy_thoughts_to_clipboard": "Gedanken in die Zwischenablage kopieren",
|
||||
"com_ui_copy_to_clipboard": "In die Zwischenablage kopieren",
|
||||
"com_ui_copy_url_to_clipboard": "URL in die Zwischenablage kopieren",
|
||||
"com_ui_create": "Erstellen",
|
||||
@@ -844,6 +862,7 @@
|
||||
"com_ui_download_backup": "Backup-Codes herunterladen",
|
||||
"com_ui_download_backup_tooltip": "Bevor Sie fortfahren, laden Sie bitte Ihre Backup-Codes herunter. Sie benötigen sie, um den Zugang wiederherzustellen, falls Sie Ihr Authentifizierungsgerät verlieren.",
|
||||
"com_ui_download_error": "Fehler beim Herunterladen der Datei. Die Datei wurde möglicherweise gelöscht.",
|
||||
"com_ui_download_error_logs": "Fehlerprotokolle herunterladen",
|
||||
"com_ui_drag_drop": "Ziehe eine beliebige Datei hierher, um sie zur Unterhaltung hinzuzufügen.",
|
||||
"com_ui_dropdown_variables": "Dropdown-Variablen:",
|
||||
"com_ui_dropdown_variables_info": "Erstelle benutzerdefinierte Dropdown-Menüs für deine Prompts: `{{variable_name:option1|option2|option3}}`",
|
||||
@@ -855,6 +874,8 @@
|
||||
"com_ui_edit_editing_image": "Bild bearbeiten\n",
|
||||
"com_ui_edit_mcp_server": "MCP-Server bearbeiten",
|
||||
"com_ui_edit_memory": "Erinnerung bearbeiten",
|
||||
"com_ui_editable_message": "Bearbeitbare Nachricht",
|
||||
"com_ui_editor_instructions": "Zieh das Bild, um es neu zu positionieren • Nutze den Zoom-Regler oder die Schaltflächen zur Größenanpassung.",
|
||||
"com_ui_empty_category": "-",
|
||||
"com_ui_endpoint": "Endpunkt",
|
||||
"com_ui_endpoint_menu": "LLM-Endpunkt-Menü",
|
||||
@@ -889,6 +910,7 @@
|
||||
"com_ui_feedback_tag_unjustified_refusal": "Mit anderer Begründung abgelehnt",
|
||||
"com_ui_field_max_length": "{{field}} darf maximal {{length}} Zeichen haben",
|
||||
"com_ui_field_required": "Dieses Feld ist erforderlich",
|
||||
"com_ui_file_input_avatar_label": "Dateiauswahl für Avatar",
|
||||
"com_ui_file_size": "Dateigröße",
|
||||
"com_ui_file_token_limit": "Datei-Token-Limit",
|
||||
"com_ui_file_token_limit_desc": "Lege ein maximales Token-Limit für die Dateiverarbeitung fest, um Kosten und Ressourcenverbrauch zu steuern.",
|
||||
@@ -934,6 +956,7 @@
|
||||
"com_ui_good_evening": "Guten Abend",
|
||||
"com_ui_good_morning": "Guten Morgen",
|
||||
"com_ui_group": "Gruppe",
|
||||
"com_ui_handoff_instructions": "Übergabebeschreibung",
|
||||
"com_ui_happy_birthday": "Es ist mein 1. Geburtstag!",
|
||||
"com_ui_hide_image_details": "Details zum Bild ausblenden",
|
||||
"com_ui_hide_password": "Passwort verbergen",
|
||||
@@ -951,11 +974,13 @@
|
||||
"com_ui_import_conversation_file_type_error": "Nicht unterstützter Importtyp",
|
||||
"com_ui_import_conversation_info": "Konversationen aus einer JSON-Datei importieren",
|
||||
"com_ui_import_conversation_success": "Konversationen erfolgreich importiert",
|
||||
"com_ui_import_conversation_upload_error": "Fehler beim Hochladen der Datei. Bitte versuch es erneut.",
|
||||
"com_ui_include_shadcnui": "Anweisungen für shadcn/ui-Komponenten einschließen",
|
||||
"com_ui_initializing": "Initialisiere...",
|
||||
"com_ui_input": "Eingabe",
|
||||
"com_ui_instructions": "Anweisungen",
|
||||
"com_ui_key": "Schlüssel",
|
||||
"com_ui_key_required": "API-Schlüssel ist erforderlich",
|
||||
"com_ui_late_night": "Schöne späte Nacht",
|
||||
"com_ui_latest_footer": "Alle KIs für alle.",
|
||||
"com_ui_latest_production_version": "Neueste Produktiv-Version",
|
||||
@@ -970,6 +995,7 @@
|
||||
"com_ui_manage": "Verwalten",
|
||||
"com_ui_marketplace": "Marktplatz",
|
||||
"com_ui_marketplace_allow_use": "Nutzung des Marktplatzes erlauben",
|
||||
"com_ui_max_file_size": "PNG, JPG oder JPEG (max. {{0}})",
|
||||
"com_ui_max_tags": "Die maximale Anzahl ist {{0}}, es werden die neuesten Werte verwendet.",
|
||||
"com_ui_mcp_authenticated_success": "MCP-Server „{{0}}“ erfolgreich authentifiziert.",
|
||||
"com_ui_mcp_configure_server": "Konfiguriere {{0}}",
|
||||
@@ -1005,6 +1031,7 @@
|
||||
"com_ui_memory_updated_items": "Aktualisierte Erinnerungen",
|
||||
"com_ui_memory_would_exceed": "Speichern nicht möglich - würde Limit um {{tokens}} Tokens überschreiten. Löschen Sie vorhandene Erinnerungen, um Platz zu schaffen.",
|
||||
"com_ui_mention": "Erwähne einen Endpunkt, Assistenten oder eine Voreinstellung, um schnell dorthin zu wechseln",
|
||||
"com_ui_message_input": "Nachrichteneingabe",
|
||||
"com_ui_min_tags": "Es können nicht mehr Werte entfernt werden, mindestens {{0}} sind erforderlich.",
|
||||
"com_ui_minimal": "Minimal",
|
||||
"com_ui_misc": "Verschiedenes",
|
||||
@@ -1064,6 +1091,8 @@
|
||||
"com_ui_privacy_policy": "Datenschutzerklärung",
|
||||
"com_ui_privacy_policy_url": "Datenschutzrichtlinie-URL",
|
||||
"com_ui_prompt": "Prompt",
|
||||
"com_ui_prompt_input": "Prompt-Eingabe",
|
||||
"com_ui_prompt_input_field": "Prompt-Texteingabefeld",
|
||||
"com_ui_prompt_name": "Prompt-Name",
|
||||
"com_ui_prompt_name_required": "Prompt-Name ist erforderlich",
|
||||
"com_ui_prompt_preview_not_shared": "Der Autor hat die Zusammenarbeit für diesen Prompt nicht erlaubt.",
|
||||
@@ -1082,25 +1111,32 @@
|
||||
"com_ui_reference_saved_memories_description": "Erlaube der KI bei den Antworten auf deine gespeicherten Erinnerungen zuzugreifen und sie zu verwenden.",
|
||||
"com_ui_refresh": "Aktualisieren",
|
||||
"com_ui_refresh_link": "Link aktualisieren",
|
||||
"com_ui_refresh_page": "Seite aktualisieren",
|
||||
"com_ui_regenerate": "Neu generieren",
|
||||
"com_ui_regenerate_backup": "Backup-Codes neu generieren",
|
||||
"com_ui_regenerating": "Generiere neu ...",
|
||||
"com_ui_region": "Region",
|
||||
"com_ui_reinitialize": "Neu initialisieren",
|
||||
"com_ui_remove_agent_from_chain": "{{0}} aus der Kette entfernen",
|
||||
"com_ui_remove_user": "{{0}} entfernen",
|
||||
"com_ui_rename": "Umbenennen",
|
||||
"com_ui_rename_conversation": "Chat umbenennen",
|
||||
"com_ui_rename_failed": "Chat konnte nicht umbenannt werden.",
|
||||
"com_ui_rename_prompt": "Prompt umbenennen",
|
||||
"com_ui_requires_auth": "Authentifizierung erforderlich",
|
||||
"com_ui_reset": "Zurücksetzen",
|
||||
"com_ui_reset_adjustments": "Anpassungen zurücksetzen",
|
||||
"com_ui_reset_var": "{{0}} zurücksetzen",
|
||||
"com_ui_reset_zoom": "Zoom zurücksetzen",
|
||||
"com_ui_resource": "Ressource",
|
||||
"com_ui_response": "Antwort",
|
||||
"com_ui_result": "Ergebnis",
|
||||
"com_ui_revoke": "Widerrufen",
|
||||
"com_ui_revoke_info": "Benutzer-API-Keys widerrufen",
|
||||
"com_ui_revoke_key_confirm": "Bist du sicher, dass du diesen Schlüssel widerrufen möchtest?",
|
||||
"com_ui_revoke_key_endpoint": "API-Schlüssel für {{0}} widerrufen",
|
||||
"com_ui_revoke_key_error": "API-Schlüssel konnte nicht widerrufen werden. Bitte versuch es erneut.",
|
||||
"com_ui_revoke_key_success": "API-Schlüssel erfolgreich widerrufen",
|
||||
"com_ui_revoke_keys": "Schlüssel widerrufen",
|
||||
"com_ui_revoke_keys_confirm": "Bist du sicher, dass du alle Schlüssel widerrufen möchtest?",
|
||||
"com_ui_role": "Rolle",
|
||||
@@ -1114,11 +1150,15 @@
|
||||
"com_ui_role_viewer": "Betrachter",
|
||||
"com_ui_role_viewer_desc": "Kann den Agenten ansehen und nutzen aber nicht bearbeiten",
|
||||
"com_ui_roleplay": "Rollenspiel",
|
||||
"com_ui_rotate": "Drehen",
|
||||
"com_ui_rotate_90": "Um 90 Grad drehen",
|
||||
"com_ui_run_code": "Code ausführen",
|
||||
"com_ui_run_code_error": "Bei der Ausführung des Codes ist ein Fehler aufgetreten",
|
||||
"com_ui_save": "Speichern",
|
||||
"com_ui_save_badge_changes": "Änderungen an Badges speichern?",
|
||||
"com_ui_save_changes": "Änderungen speichern",
|
||||
"com_ui_save_key_error": "API-Schlüssel konnte nicht gespeichert werden. Bitte versuch es erneut.",
|
||||
"com_ui_save_key_success": "API-Schlüssel erfolgreich gespeichert",
|
||||
"com_ui_save_submit": "Speichern & Absenden",
|
||||
"com_ui_saved": "Gespeichert!",
|
||||
"com_ui_saving": "Sicherung läuft...",
|
||||
@@ -1154,6 +1194,7 @@
|
||||
"com_ui_share_everyone": "Mit allen teilen",
|
||||
"com_ui_share_everyone_description_var": "{{resource}} wird für alle verfügbar sein. Bitte stelle sicher, dass {{resource}} wirklich für alle freigegeben werden soll. Sei vorsichtig mit deinen Daten.",
|
||||
"com_ui_share_link_to_chat": "Link zum Chat teilen",
|
||||
"com_ui_share_qr_code_description": "QR-Code zum Teilen dieses Konversationslinks",
|
||||
"com_ui_share_update_message": "Ihr Name, benutzerdefinierte Anweisungen und alle Nachrichten, die du nach dem Teilen hinzufügen, bleiben privat.",
|
||||
"com_ui_share_var": "{{0}} teilen",
|
||||
"com_ui_shared_link_bulk_delete_success": "Geteilte Links erfolgreich gelöscht",
|
||||
@@ -1200,6 +1241,7 @@
|
||||
"com_ui_tool_info": "Tool Information",
|
||||
"com_ui_tool_more_info": "Mehr Information über dieses Tool",
|
||||
"com_ui_tools": "Werkzeuge",
|
||||
"com_ui_transferred_to": "Übergeben an",
|
||||
"com_ui_travel": "Reisen",
|
||||
"com_ui_trust_app": "Ich vertraue dieser Anwendung",
|
||||
"com_ui_try_adjusting_search": "Versuche, deine Suchbegriffe anzupassen",
|
||||
@@ -1215,6 +1257,8 @@
|
||||
"com_ui_update_mcp_success": "MCP erfolgreich erstellt oder aktualisiert",
|
||||
"com_ui_upload": "Hochladen",
|
||||
"com_ui_upload_agent_avatar": "Agenten-Avatar erfolgreich aktualisiert",
|
||||
"com_ui_upload_agent_avatar_label": "Avatarbild des Agenten hochladen",
|
||||
"com_ui_upload_avatar_label": "Avatarbild hochladen",
|
||||
"com_ui_upload_code_files": "Hochladen für Code-Interpreter",
|
||||
"com_ui_upload_delay": "Das Hochladen von \"{{0}}\" dauert etwas länger. Bitte warte, während die Datei für den Abruf indexiert wird.",
|
||||
"com_ui_upload_error": "Beim Hochladen Ihrer Datei ist ein Fehler aufgetreten",
|
||||
@@ -1225,7 +1269,7 @@
|
||||
"com_ui_upload_image_input": "Bild hochladen",
|
||||
"com_ui_upload_invalid": "Ungültige Datei zum Hochladen. Muss ein Bild sein und das Limit nicht überschreiten",
|
||||
"com_ui_upload_invalid_var": "Ungültige Datei zum Hochladen. Muss ein Bild sein und {{0}} MB nicht überschreiten",
|
||||
"com_ui_upload_ocr_text": "Hochladen als Text mit OCR",
|
||||
"com_ui_upload_ocr_text": "Hochladen als Text",
|
||||
"com_ui_upload_provider": "Hochladen zum KI-Anbieter",
|
||||
"com_ui_upload_success": "Datei erfolgreich hochgeladen",
|
||||
"com_ui_upload_type": "Upload-Typ auswählen",
|
||||
@@ -1276,5 +1320,8 @@
|
||||
"com_ui_x_selected": "{{0}} ausgewählt",
|
||||
"com_ui_yes": "Ja",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_ui_zoom_in": "Heranzoomen",
|
||||
"com_ui_zoom_level": "Zoomstufe",
|
||||
"com_ui_zoom_out": "Herauszoomen",
|
||||
"com_user_message": "Du"
|
||||
}
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
|
||||
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
|
||||
"com_error_no_user_key": "No key found. Please provide a key and try again.",
|
||||
"com_error_refusal": "Response refused by safety filters. Rewrite your message and try again. If you encounter this frequently while using Claude Sonnet 4.5 or Opus 4.1, you can try Sonnet 4, which has different usage restrictions.",
|
||||
"com_file_pages": "Pages: {{pages}}",
|
||||
"com_file_source": "File",
|
||||
"com_file_unknown": "Unknown File",
|
||||
@@ -713,6 +714,7 @@
|
||||
"com_ui_assistants_output": "Assistants Output",
|
||||
"com_ui_at_least_one_owner_required": "At least one owner is required",
|
||||
"com_ui_attach_error": "Cannot attach file. Create or select a conversation, or try refreshing the page.",
|
||||
"com_ui_attach_error_disabled": "File uploads are disabled for this endpoint",
|
||||
"com_ui_attach_error_openai": "Cannot attach Assistant files to other endpoints",
|
||||
"com_ui_attach_error_size": "File size limit exceeded for endpoint:",
|
||||
"com_ui_attach_error_type": "Unsupported file type for endpoint:",
|
||||
@@ -767,10 +769,12 @@
|
||||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_cancelled": "Cancelled",
|
||||
"com_ui_category": "Category",
|
||||
"com_ui_change_version": "Change Version",
|
||||
"com_ui_chat": "Chat",
|
||||
"com_ui_chat_history": "Chat History",
|
||||
"com_ui_clear": "Clear",
|
||||
"com_ui_clear_all": "Clear all",
|
||||
"com_ui_click_to_close": "Click to close",
|
||||
"com_ui_client_id": "Client ID",
|
||||
"com_ui_client_secret": "Client Secret",
|
||||
"com_ui_close": "Close",
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"com_error_moderation": "Šķiet, ka mūsu moderācijas sistēma ir atzīmējusi nosūtīto saturu kā neatbilstošu mūsu vadlīnijām. Mēs nevaram turpināt darbu ar šo konkrēto tēmu. Ja jums ir vēl kādi jautājumi vai tēmas, kuras vēlaties izpētīt, lūdzu, rediģējiet savu ziņu vai izveidojiet jaunu sarunu.",
|
||||
"com_error_no_base_url": "Nav atrasts bāzes URL. Lūdzu, norādiet to un mēģiniet vēlreiz.",
|
||||
"com_error_no_user_key": "Atslēga nav atrasta. Lūdzu, norādiet atslēgu un mēģiniet vēlreiz.",
|
||||
"com_error_refusal": "Drošības filtri noraidīja atbildi. Pārrakstiet savu ziņojumu un mēģiniet vēlreiz. Ja, lietojot Claude Sonnet 4.5 vai Opus 4.1, ar šo problēmu bieži saskaraties, varat izmēģināt Sonnet 4, kuram ir atšķirīgi lietošanas ierobežojumi.",
|
||||
"com_file_pages": "Lapas: {{pages}}",
|
||||
"com_file_source": "Fails",
|
||||
"com_file_unknown": "Nezināms fails",
|
||||
@@ -594,7 +595,7 @@
|
||||
"com_show_examples": "Rādīt piemērus",
|
||||
"com_sidepanel_agent_builder": "Aģentu veidotājs",
|
||||
"com_sidepanel_assistant_builder": "Asistenta veidotāju",
|
||||
"com_sidepanel_attach_files": "Pievienot failus",
|
||||
"com_sidepanel_attach_files": "Failu Pārvaldība",
|
||||
"com_sidepanel_conversation_tags": "Grāmatzīmes",
|
||||
"com_sidepanel_hide_panel": "Slēpt paneli",
|
||||
"com_sidepanel_manage_files": "Pārvaldīt failus",
|
||||
@@ -659,6 +660,17 @@
|
||||
"com_ui_agent_deleted": "Aģents veiksmīgi dzēsts",
|
||||
"com_ui_agent_duplicate_error": "Dublējot aģentu, radās kļūda.",
|
||||
"com_ui_agent_duplicated": "Aģents veiksmīgi dublēts",
|
||||
"com_ui_agent_handoff_add": "Pievienot nodošanas aģentu",
|
||||
"com_ui_agent_handoff_description": "Nodošanas apraksts",
|
||||
"com_ui_agent_handoff_description_placeholder": "piem., pārsūtīšana datu analītiķim statistiskai analīzei",
|
||||
"com_ui_agent_handoff_info": "Konfigurēt aģentus, kuriem šis aģents var pāradresēt sarunas, ja nepieciešama speciālizēta informācija.",
|
||||
"com_ui_agent_handoff_info_2": "Katrā nodošanas reizē tiek izveidots nodošanas rīks, kas nodrošina netraucētu maršrutēšanu pie specializētiem aģentiem, izmantojot kontekstu.",
|
||||
"com_ui_agent_handoff_max": "Maksimālais daudzums nodošanas aģentu sasniegts {{0}}.",
|
||||
"com_ui_agent_handoff_prompt": "Caurlaides saturs",
|
||||
"com_ui_agent_handoff_prompt_key": "Satura parametra nosaukums (noklusējums: \"instrukcijas\")",
|
||||
"com_ui_agent_handoff_prompt_key_placeholder": "Apzīmējiet nodoto saturu (noklusējums: \"instrukcijas\")",
|
||||
"com_ui_agent_handoff_prompt_placeholder": "Norādiet šim aģentam, kādu saturu ģenerēt un nodot nodošanas aģentam. Lai iespējotu šo funkciju, šeit ir jāpievieno kaut kas.",
|
||||
"com_ui_agent_handoffs": "Aģentu nodošanas",
|
||||
"com_ui_agent_name_is_required": "Obligāti jānorāda aģenta nosaukums",
|
||||
"com_ui_agent_recursion_limit": "Maksimālais aģenta soļu skaits",
|
||||
"com_ui_agent_recursion_limit_info": "Ierobežo, cik soļus aģents var veikt vienā izpildes reizē, pirms sniedz galīgo atbildi. Noklusējuma vērtība ir 25 soļi. Solis ir vai nu AI API pieprasījums, vai rīka lietošanas kārta. Piemēram, pamata rīka mijiedarbība ietver 3 soļus: sākotnējo pieprasījumu, rīka lietošanu un turpmāko pieprasījumu.",
|
||||
@@ -702,6 +714,7 @@
|
||||
"com_ui_assistants_output": "Asistentu izvade",
|
||||
"com_ui_at_least_one_owner_required": "Nepieciešams vismaz viens īpašnieks",
|
||||
"com_ui_attach_error": "Nevar pievienot failu. Izveidojiet vai atlasiet sarunu vai mēģiniet atsvaidzināt lapu.",
|
||||
"com_ui_attach_error_disabled": "Šim galapunktam failu augšupielāde ir atspējota.",
|
||||
"com_ui_attach_error_openai": "Nevar pievienot asistenta failus citiem galapunktiem",
|
||||
"com_ui_attach_error_size": "Galapunkta faila lieluma ierobežojums ir pārsniegts:",
|
||||
"com_ui_attach_error_type": "Neatbalstīts faila tips galapunktam:",
|
||||
@@ -731,6 +744,7 @@
|
||||
"com_ui_basic": "Pamata",
|
||||
"com_ui_basic_auth_header": "Pamata autorizācijas galvene",
|
||||
"com_ui_bearer": "Nesējs",
|
||||
"com_ui_beta": "Beta versija",
|
||||
"com_ui_bookmark_delete_confirm": "Vai tiešām vēlaties dzēst šo grāmatzīmi?",
|
||||
"com_ui_bookmarks": "Grāmatzīmes",
|
||||
"com_ui_bookmarks_add": "Pievienot grāmatzīmi",
|
||||
@@ -755,10 +769,12 @@
|
||||
"com_ui_cancel": "Atcelt",
|
||||
"com_ui_cancelled": "Atcelts",
|
||||
"com_ui_category": "Kategorija",
|
||||
"com_ui_change_version": "Mainīt versiju",
|
||||
"com_ui_chat": "Saruna",
|
||||
"com_ui_chat_history": "Sarunu vēsture",
|
||||
"com_ui_clear": "Notīrīt",
|
||||
"com_ui_clear_all": "Notīrīt visu",
|
||||
"com_ui_click_to_close": "Noklikšķiniet, lai aizvērtu",
|
||||
"com_ui_client_id": "Klienta ID",
|
||||
"com_ui_client_secret": "Klienta noslēpums",
|
||||
"com_ui_close": "Aizvērt",
|
||||
@@ -949,6 +965,7 @@
|
||||
"com_ui_good_evening": "Labvakar",
|
||||
"com_ui_good_morning": "Labrīt",
|
||||
"com_ui_group": "Grupa",
|
||||
"com_ui_handoff_instructions": "Nodošanas instrukcijas",
|
||||
"com_ui_happy_birthday": "Man šodien ir pirmā dzimšanas diena!",
|
||||
"com_ui_hide_image_details": "Slēpt attēla detaļas",
|
||||
"com_ui_hide_password": "Paslēpt paroli",
|
||||
@@ -1234,6 +1251,7 @@
|
||||
"com_ui_tool_info": "Informācija par rīku",
|
||||
"com_ui_tool_more_info": "Vairāk informācijas par šo rīku",
|
||||
"com_ui_tools": "Rīki",
|
||||
"com_ui_transferred_to": "Pārcelts uz",
|
||||
"com_ui_travel": "Ceļošana",
|
||||
"com_ui_trust_app": "Es uzticos šai lietotnei",
|
||||
"com_ui_try_adjusting_search": "Mēģiniet pielāgot meklēšanas vaicājumus",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"chat_direction_left_to_right": "这里需要放点东西,当前是空的",
|
||||
"chat_direction_right_to_left": "这里需要放点东西,当前是空的",
|
||||
"chat_direction_left_to_right": "从左到右",
|
||||
"chat_direction_right_to_left": "从右到左",
|
||||
"com_a11y_ai_composing": "AI 仍在撰写中。",
|
||||
"com_a11y_end": "AI 已完成回复。",
|
||||
"com_a11y_start": "AI 已开始回复。",
|
||||
@@ -261,10 +261,10 @@
|
||||
"com_endpoint_context_info": "可用于上下文的最大词元数。用于控制每个请求发送的词元数量。如果未指定,将根据已知模型的上下文大小使用系统默认值。设置较高的值可能会导致错误和/或更高的词元成本。",
|
||||
"com_endpoint_context_tokens": "最大上下文词元数",
|
||||
"com_endpoint_custom_name": "自定义名称",
|
||||
"com_endpoint_default": "初始值",
|
||||
"com_endpoint_default_blank": "初始值:空白",
|
||||
"com_endpoint_default_empty": "初始值:空",
|
||||
"com_endpoint_default_with_num": "初始值:{{0}}",
|
||||
"com_endpoint_default": "默认值",
|
||||
"com_endpoint_default_blank": "默认值:空白",
|
||||
"com_endpoint_default_empty": "默认值:空",
|
||||
"com_endpoint_default_with_num": "默认值:{{0}}",
|
||||
"com_endpoint_deprecated": "已弃用",
|
||||
"com_endpoint_deprecated_info": "此端点已被弃用并可能在未来的版本中删除,请改用智能体端点",
|
||||
"com_endpoint_deprecated_info_a11y": "此插件端点已被弃用并可能在未来的版本中删除,请改用智能体端点",
|
||||
@@ -386,6 +386,7 @@
|
||||
"com_files_download_progress": "第 {{0}} 个文件,共 {{1}} 个",
|
||||
"com_files_downloading": "正在下载文件",
|
||||
"com_files_filter": "筛选文件...",
|
||||
"com_files_filter_by": "按以下条件筛选文件...",
|
||||
"com_files_no_results": "无结果。",
|
||||
"com_files_number_selected": "已选择 {{0}} 个文件(共 {{1}} 个文件)",
|
||||
"com_files_preparing_download": "准备下载...",
|
||||
@@ -631,9 +632,10 @@
|
||||
"com_ui_action_button": "操作按钮",
|
||||
"com_ui_active": "活动",
|
||||
"com_ui_add": "添加",
|
||||
"com_ui_add_api_key": "添加 API 密钥",
|
||||
"com_ui_add_mcp": "添加 MCP",
|
||||
"com_ui_add_mcp_server": "添加 MCP 服务器",
|
||||
"com_ui_add_model_preset": "添加一个模型或预设以获得额外的回复",
|
||||
"com_ui_add_model_preset": "添加一个模型或预设以获得额外的响应",
|
||||
"com_ui_add_multi_conversation": "添加多个对话",
|
||||
"com_ui_adding_details": "添加细节",
|
||||
"com_ui_admin": "管理",
|
||||
@@ -652,11 +654,22 @@
|
||||
"com_ui_agent_category_selector_aria": "智能体类别选择器",
|
||||
"com_ui_agent_chain": "智能体链(Mixture-of-Agents)",
|
||||
"com_ui_agent_chain_info": "启用创建智能体序列。每个智能体都可以访问智能体链中先前智能体的输出。基于 “Mixture-of-Agents” 架构,智能体使用先前的输出作为辅助信息。",
|
||||
"com_ui_agent_chain_max": "您已达到智能体 {{0}} 的最大值。",
|
||||
"com_ui_agent_chain_max": "您已达到 {{0}} 个智能体的最大值",
|
||||
"com_ui_agent_delete_error": "删除智能体时出现错误",
|
||||
"com_ui_agent_deleted": "智能体已成功删除",
|
||||
"com_ui_agent_duplicate_error": "复制智能体时发生错误",
|
||||
"com_ui_agent_duplicated": "智能体复制成功",
|
||||
"com_ui_agent_handoff_add": "添加接力智能体",
|
||||
"com_ui_agent_handoff_description": "接力说明",
|
||||
"com_ui_agent_handoff_description_placeholder": "例如:转交给数据分析师进行统计分析",
|
||||
"com_ui_agent_handoff_info": "配置接力智能体,以便在需要特定专业知识时,可以将对话转移至接力智能体。",
|
||||
"com_ui_agent_handoff_info_2": "每次接力都会创建一个传输工具,将上下文无缝路由到专用智能体。",
|
||||
"com_ui_agent_handoff_max": "以达到 {{0}} 个接力智能体的最大值",
|
||||
"com_ui_agent_handoff_prompt": "传递内容",
|
||||
"com_ui_agent_handoff_prompt_key": "内容参数名称(默认值:“指令”)",
|
||||
"com_ui_agent_handoff_prompt_key_placeholder": "标记传递的内容(默认值:“指令”)",
|
||||
"com_ui_agent_handoff_prompt_placeholder": "告诉智能体要生成什么内容,并将其传递给接力智能体。您需要在这里添加一些内容以启用此功能",
|
||||
"com_ui_agent_handoffs": "智能体接力",
|
||||
"com_ui_agent_name_is_required": "智能体名称为必填项",
|
||||
"com_ui_agent_recursion_limit": "最大智能体步骤数",
|
||||
"com_ui_agent_recursion_limit_info": "限制智能体在给出最终响应之前可执行的步骤数。默认为 25 个步骤。步骤可以是一次 AI API 请求或一次工具使用。例如,一个基本的工具交互需要 3 个步骤:初始请求、工具使用和后续请求。",
|
||||
@@ -717,6 +730,7 @@
|
||||
"com_ui_azure": "Azure",
|
||||
"com_ui_azure_ad": "Entra ID",
|
||||
"com_ui_back": "后退",
|
||||
"com_ui_back_to_builder": "返回构建器",
|
||||
"com_ui_back_to_chat": "返回对话",
|
||||
"com_ui_back_to_prompts": "返回提示词",
|
||||
"com_ui_backup_code_number": "代码 #{{number}}",
|
||||
@@ -728,6 +742,7 @@
|
||||
"com_ui_basic": "基本",
|
||||
"com_ui_basic_auth_header": "Basic authorization header",
|
||||
"com_ui_bearer": "Bearer",
|
||||
"com_ui_beta": "测试",
|
||||
"com_ui_bookmark_delete_confirm": "您确定要删除此书签吗?",
|
||||
"com_ui_bookmarks": "书签",
|
||||
"com_ui_bookmarks_add": "添加书签",
|
||||
@@ -783,6 +798,8 @@
|
||||
"com_ui_copied_to_clipboard": "已复制到剪贴板",
|
||||
"com_ui_copy_code": "复制代码",
|
||||
"com_ui_copy_link": "复制链接",
|
||||
"com_ui_copy_stack_trace": "复制堆栈跟踪",
|
||||
"com_ui_copy_thoughts_to_clipboard": "复制思考内容到剪贴板",
|
||||
"com_ui_copy_to_clipboard": "复制到剪贴板",
|
||||
"com_ui_copy_url_to_clipboard": "复制 URL 到剪贴板",
|
||||
"com_ui_create": "创建",
|
||||
@@ -848,6 +865,7 @@
|
||||
"com_ui_download_backup": "下载备份代码",
|
||||
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
|
||||
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
|
||||
"com_ui_download_error_logs": "下载错误日志",
|
||||
"com_ui_drag_drop": "将任意文件拖放到此处以添加到对话中",
|
||||
"com_ui_dropdown_variables": "下拉变量:",
|
||||
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
|
||||
@@ -859,6 +877,7 @@
|
||||
"com_ui_edit_editing_image": "编辑图片",
|
||||
"com_ui_edit_mcp_server": "编辑 MCP 服务器",
|
||||
"com_ui_edit_memory": "编辑记忆",
|
||||
"com_ui_editable_message": "可编辑的消息",
|
||||
"com_ui_editor_instructions": "拖动图片调整位置 • 使用缩放滑块或按钮调整大小",
|
||||
"com_ui_empty_category": "-",
|
||||
"com_ui_endpoint": "端点",
|
||||
@@ -940,6 +959,7 @@
|
||||
"com_ui_good_evening": "晚上好",
|
||||
"com_ui_good_morning": "早上好",
|
||||
"com_ui_group": "群组",
|
||||
"com_ui_handoff_instructions": "接力指令",
|
||||
"com_ui_happy_birthday": "这是我的第一个生日!",
|
||||
"com_ui_hide_image_details": "隐藏图片详情",
|
||||
"com_ui_hide_password": "隐藏密码",
|
||||
@@ -1014,6 +1034,7 @@
|
||||
"com_ui_memory_updated_items": "已更新的记忆",
|
||||
"com_ui_memory_would_exceed": "无法保存 - 将超过 {{tokens}} 词元限制。删除现有记忆以释放空间。",
|
||||
"com_ui_mention": "提及一个端点、助手或预设以快速切换到它",
|
||||
"com_ui_message_input": "消息输入",
|
||||
"com_ui_min_tags": "无法再移除更多值,至少需要保留 {{0}} 个。",
|
||||
"com_ui_minimal": "最小值",
|
||||
"com_ui_misc": "杂项",
|
||||
@@ -1074,6 +1095,8 @@
|
||||
"com_ui_privacy_policy_url": "隐私政策链接",
|
||||
"com_ui_prompt": "提示词",
|
||||
"com_ui_prompt_groups": "提示词组列表",
|
||||
"com_ui_prompt_input": "提示词输入",
|
||||
"com_ui_prompt_input_field": "提示词文本输入框",
|
||||
"com_ui_prompt_name": "提示词名称",
|
||||
"com_ui_prompt_name_required": "提示词名称为必填项",
|
||||
"com_ui_prompt_preview_not_shared": "作者未允许对此提示词进行协作。",
|
||||
@@ -1092,11 +1115,13 @@
|
||||
"com_ui_reference_saved_memories_description": "允许助手在回复时参考并使用您保存的记忆",
|
||||
"com_ui_refresh": "刷新",
|
||||
"com_ui_refresh_link": "刷新链接",
|
||||
"com_ui_refresh_page": "刷新页面",
|
||||
"com_ui_regenerate": "重新生成",
|
||||
"com_ui_regenerate_backup": "重新生成备份代码",
|
||||
"com_ui_regenerating": "重新生成中...",
|
||||
"com_ui_region": "区域",
|
||||
"com_ui_reinitialize": "重新初始化",
|
||||
"com_ui_remove_agent_from_chain": "从链中移除 {{0}}",
|
||||
"com_ui_remove_user": "移除 {{0}}",
|
||||
"com_ui_rename": "重命名",
|
||||
"com_ui_rename_conversation": "重命名对话",
|
||||
@@ -1108,6 +1133,7 @@
|
||||
"com_ui_reset_var": "重置 {{0}}",
|
||||
"com_ui_reset_zoom": "重置缩放",
|
||||
"com_ui_resource": "资源",
|
||||
"com_ui_response": "响应",
|
||||
"com_ui_result": "结果",
|
||||
"com_ui_revoke": "撤销",
|
||||
"com_ui_revoke_info": "撤销所有用户提供的凭据",
|
||||
@@ -1172,6 +1198,7 @@
|
||||
"com_ui_share_everyone": "与所有人共享",
|
||||
"com_ui_share_everyone_description_var": "该{{resource}}将对所有人可用。请确保该{{resource}}适合与所有人共享。请注意保护您的数据。",
|
||||
"com_ui_share_link_to_chat": "共享链接到聊天",
|
||||
"com_ui_share_qr_code_description": "用于共享此对话链接的二维码",
|
||||
"com_ui_share_update_message": "您的姓名、自定义指令以及您在共享后添加的任何消息将保持私密。",
|
||||
"com_ui_share_var": "共享 {{0}}",
|
||||
"com_ui_shared_link_bulk_delete_success": "成功删除分享链接",
|
||||
@@ -1192,7 +1219,7 @@
|
||||
"com_ui_special_var_iso_datetime": "UTC ISO 日期时间",
|
||||
"com_ui_special_variables": "特殊变量:",
|
||||
"com_ui_special_variables_more_info": "您可以从下拉菜单中选择特殊变量:`{{current_date}}`(今天的日期和星期)、`{{current_datetime}}`(本地日期和时间)、`{{utc_iso_datetime}}`(UTC ISO 格式的日期时间)以及`{{current_user}}`(您的账户名称)。",
|
||||
"com_ui_speech_while_submitting": "正在生成回复时无法提交语音",
|
||||
"com_ui_speech_while_submitting": "正在生成响应时无法提交语音",
|
||||
"com_ui_sr_actions_menu": "打开 \"{{0}}\" 的操作菜单",
|
||||
"com_ui_stop": "停止",
|
||||
"com_ui_storage": "存储",
|
||||
@@ -1218,6 +1245,7 @@
|
||||
"com_ui_tool_info": "工具信息",
|
||||
"com_ui_tool_more_info": "有关此工具的更多信息",
|
||||
"com_ui_tools": "工具",
|
||||
"com_ui_transferred_to": "转移至",
|
||||
"com_ui_travel": "旅行",
|
||||
"com_ui_trust_app": "我信任此应用",
|
||||
"com_ui_try_adjusting_search": "尝试调整您的搜索条件",
|
||||
@@ -1233,6 +1261,7 @@
|
||||
"com_ui_update_mcp_success": "已成功创建或更新 MCP",
|
||||
"com_ui_upload": "上传",
|
||||
"com_ui_upload_agent_avatar": "成功更新智能体头像",
|
||||
"com_ui_upload_agent_avatar_label": "上传智能体头像图片",
|
||||
"com_ui_upload_avatar_label": "上传头像图片",
|
||||
"com_ui_upload_code_files": "上传代码解释器文件",
|
||||
"com_ui_upload_delay": "上传 “{{0}}” 时比预期花了更长时间。文件正在进行检索索引,请稍候。",
|
||||
|
||||
@@ -2715,6 +2715,7 @@ html {
|
||||
.animate-pulse-slow {
|
||||
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -2730,10 +2731,6 @@ html {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.scale-98 {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Chat Badges Animation */
|
||||
|
||||
@keyframes ios-wiggle {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig, TConfig } from 'librechat-data-provider';
|
||||
import {
|
||||
getEndpointField,
|
||||
getAvailableEndpoints,
|
||||
getEndpointsFilter,
|
||||
mapEndpoints,
|
||||
} from './endpoints';
|
||||
import { getAvailableEndpoints, getEndpointsFilter, mapEndpoints } from './endpoints';
|
||||
|
||||
const mockEndpointsConfig: TEndpointsConfig = {
|
||||
[EModelEndpoint.openAI]: { type: undefined, iconURL: 'openAI_icon.png', order: 0 },
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
defaultEndpoints,
|
||||
modularEndpoints,
|
||||
LocalStorageKeys,
|
||||
getEndpointField,
|
||||
isAgentsEndpoint,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
@@ -58,24 +59,6 @@ export const getAvailableEndpoints = (
|
||||
return availableEndpoints;
|
||||
};
|
||||
|
||||
/** Get the specified field from the endpoint config */
|
||||
export function getEndpointField<K extends keyof t.TConfig>(
|
||||
endpointsConfig: t.TEndpointsConfig | undefined | null,
|
||||
endpoint: EModelEndpoint | string | null | undefined,
|
||||
property: K,
|
||||
): t.TConfig[K] | undefined {
|
||||
if (!endpointsConfig || endpoint === null || endpoint === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = endpointsConfig[endpoint];
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return config[property];
|
||||
}
|
||||
|
||||
export function mapEndpoints(endpointsConfig: t.TEndpointsConfig) {
|
||||
const filter = getEndpointsFilter(endpointsConfig);
|
||||
return getAvailableEndpoints(filter, endpointsConfig).sort(
|
||||
|
||||
@@ -235,7 +235,13 @@ export const validateFiles = ({
|
||||
toolResource?: string;
|
||||
fileConfig: FileConfig | null;
|
||||
}) => {
|
||||
const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes } = endpointFileConfig;
|
||||
const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes, disabled } =
|
||||
endpointFileConfig;
|
||||
/** Block all uploads if the endpoint is explicitly disabled */
|
||||
if (disabled === true) {
|
||||
setError('com_ui_attach_error_disabled');
|
||||
return false;
|
||||
}
|
||||
const existingFiles = Array.from(files.values());
|
||||
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
|
||||
if (incomingTotalSize === 0) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './forms';
|
||||
export * from './agents';
|
||||
export * from './drafts';
|
||||
export * from './convos';
|
||||
export * from './routes';
|
||||
export * from './presets';
|
||||
export * from './prompts';
|
||||
export * from './textarea';
|
||||
|
||||
7
client/src/utils/routes.ts
Normal file
7
client/src/utils/routes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
const matchesRouteStart = (pathname: string, pattern: string) =>
|
||||
matchPath({ path: pattern, end: false }, pathname) != null;
|
||||
|
||||
export const isArtifactRoute = (pathname: string) =>
|
||||
matchesRouteStart(pathname, '/c/*') || matchesRouteStart(pathname, '/share/*');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user