Compare commits
104 Commits
v0.7.4
...
tag-window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30aaf4e0d4 | ||
|
|
5aee9db6de | ||
|
|
6a8d4e43db | ||
|
|
e39a3bafec | ||
|
|
b1eb069931 | ||
|
|
7b20679463 | ||
|
|
efbed07569 | ||
|
|
37002edbba | ||
|
|
ee126a2350 | ||
|
|
e026fc7009 | ||
|
|
8f33fd5cc1 | ||
|
|
b16079915d | ||
|
|
336c7ad21a | ||
|
|
0dd0354a4e | ||
|
|
10f436521e | ||
|
|
ac352c9878 | ||
|
|
e3bcfc560d | ||
|
|
fa0032d91d | ||
|
|
8c1db607e5 | ||
|
|
bedc91adcd | ||
|
|
2ac9fd3aed | ||
|
|
dc5b597a64 | ||
|
|
48ddf4039e | ||
|
|
f2a516db02 | ||
|
|
f1fb8e991c | ||
|
|
15068fdfab | ||
|
|
4c810fa5db | ||
|
|
8ce70a41b7 | ||
|
|
068ec2fceb | ||
|
|
e2e42db24a | ||
|
|
5be1ffe490 | ||
|
|
85711d8ada | ||
|
|
5bf61c3cdf | ||
|
|
9abf941085 | ||
|
|
8e1807d02b | ||
|
|
66d5a1a368 | ||
|
|
c8d9af42e5 | ||
|
|
b3e3788261 | ||
|
|
0a54489842 | ||
|
|
6ee70acdc5 | ||
|
|
cd749a2bcb | ||
|
|
d86d4f5c17 | ||
|
|
fc6eb9f77f | ||
|
|
85df66265d | ||
|
|
801b0de49b | ||
|
|
24d74044e4 | ||
|
|
0340b4acb9 | ||
|
|
8c162842d6 | ||
|
|
80e1bdc282 | ||
|
|
a267f6e0da | ||
|
|
f742b9972e | ||
|
|
bbaa0ee1cf | ||
|
|
62881fee54 | ||
|
|
34fd960d54 | ||
|
|
5694ad4e55 | ||
|
|
bd701c197e | ||
|
|
9bfe40bfcd | ||
|
|
07f520100d | ||
|
|
d52e81bde6 | ||
|
|
88d8706757 | ||
|
|
d54458b3a6 | ||
|
|
ea5140ff0f | ||
|
|
967e8a1e92 | ||
|
|
f86e9dd04c | ||
|
|
366e4c5adb | ||
|
|
596ecc6969 | ||
|
|
0c5568b80b | ||
|
|
98b437edd5 | ||
|
|
ba9c351435 | ||
|
|
58334dffea | ||
|
|
45479d468e | ||
|
|
14ddfec9b7 | ||
|
|
5de3b8a148 | ||
|
|
d4c0f7267a | ||
|
|
cebb3751c1 | ||
|
|
598e2be225 | ||
|
|
8ca1e4f94f | ||
|
|
87d95a9d82 | ||
|
|
b22f1c166e | ||
|
|
683702d555 | ||
|
|
d3a20357e9 | ||
|
|
bbb9324447 | ||
|
|
a45b384bbc | ||
|
|
9f4c516615 | ||
|
|
16c9aed1bb | ||
|
|
3826af5909 | ||
|
|
6ad65ff065 | ||
|
|
91fc89076f | ||
|
|
f8a5dad265 | ||
|
|
96581d56df | ||
|
|
7f50d2f7c0 | ||
|
|
93cf7bab02 | ||
|
|
e47c3f40f0 | ||
|
|
0ba08b15de | ||
|
|
16e1f74a6c | ||
|
|
dba704079c | ||
|
|
bcde0beb47 | ||
|
|
741e3e8395 | ||
|
|
dc8d30ad90 | ||
|
|
e3ebcfd2b1 | ||
|
|
6655304753 | ||
|
|
05696233a9 | ||
|
|
8cbb6ba166 | ||
|
|
02847af580 |
10
.env.example
10
.env.example
@@ -65,6 +65,7 @@ PROXY=
|
||||
# ANYSCALE_API_KEY=
|
||||
# APIPIE_API_KEY=
|
||||
# COHERE_API_KEY=
|
||||
# DEEPSEEK_API_KEY=
|
||||
# DATABRICKS_API_KEY=
|
||||
# FIREWORKS_API_KEY=
|
||||
# GROQ_API_KEY=
|
||||
@@ -74,6 +75,7 @@ PROXY=
|
||||
# PERPLEXITY_API_KEY=
|
||||
# SHUTTLEAI_API_KEY=
|
||||
# TOGETHERAI_API_KEY=
|
||||
# UNIFY_API_KEY=
|
||||
|
||||
#============#
|
||||
# Anthropic #
|
||||
@@ -147,7 +149,7 @@ GOOGLE_KEY=user_provided
|
||||
#============#
|
||||
|
||||
OPENAI_API_KEY=user_provided
|
||||
# OPENAI_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
|
||||
# OPENAI_MODELS=gpt-4o,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
|
||||
|
||||
DEBUG_OPENAI=false
|
||||
|
||||
@@ -429,10 +431,10 @@ ALLOW_SHARED_LINKS_PUBLIC=true
|
||||
# Static File Cache Control #
|
||||
#==============================#
|
||||
|
||||
# Leave commented out to use default of 1 month for max-age and 1 week for s-maxage
|
||||
# Leave commented out to use defaults: 1 day (86400 seconds) for s-maxage and 2 days (172800 seconds) for max-age
|
||||
# NODE_ENV must be set to production for these to take effect
|
||||
# STATIC_CACHE_MAX_AGE=604800
|
||||
# STATIC_CACHE_S_MAX_AGE=259200
|
||||
# STATIC_CACHE_MAX_AGE=172800
|
||||
# STATIC_CACHE_S_MAX_AGE=86400
|
||||
|
||||
# If you have another service in front of your LibreChat doing compression, disable express based compression here
|
||||
# DISABLE_COMPRESSION=true
|
||||
|
||||
11
.github/workflows/a11y.yml
vendored
11
.github/workflows/a11y.yml
vendored
@@ -4,14 +4,23 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'client/src/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_workflow:
|
||||
description: 'Set to true to run this workflow'
|
||||
required: true
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
axe-linter:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat') ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_workflow == 'true')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dequelabs/axe-linter-action@v1
|
||||
with:
|
||||
api_key: ${{ secrets.AXE_LINTER_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch LibreChat (debug)",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"program": "${workspaceFolder}/api/server/index.js",
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.7.4
|
||||
# v0.7.5-rc1
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
# v0.7.4
|
||||
# Dockerfile.multi
|
||||
# v0.7.5-rc1
|
||||
|
||||
# Build API, Client and Data Provider
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache add curl
|
||||
RUN npm config set fetch-retry-maxtimeout 600000 && \
|
||||
npm config set fetch-retries 5 && \
|
||||
npm config set fetch-retry-mintimeout 15000
|
||||
COPY package*.json ./
|
||||
COPY packages/data-provider/package*.json ./packages/data-provider/
|
||||
COPY client/package*.json ./client/
|
||||
COPY api/package*.json ./api/
|
||||
RUN npm ci
|
||||
|
||||
# Build data-provider
|
||||
FROM base AS data-provider-build
|
||||
WORKDIR /app/packages/data-provider
|
||||
COPY ./packages/data-provider ./
|
||||
RUN npm install; npm cache clean --force
|
||||
COPY packages/data-provider ./
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
|
||||
# React client build
|
||||
# Client build
|
||||
FROM base AS client-build
|
||||
WORKDIR /app/client
|
||||
COPY ./client/package*.json ./
|
||||
# Copy data-provider to client's node_modules
|
||||
COPY --from=data-provider-build /app/packages/data-provider/ /app/client/node_modules/librechat-data-provider/
|
||||
RUN npm install; npm cache clean --force
|
||||
COPY ./client/ ./
|
||||
COPY client ./
|
||||
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
|
||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
|
||||
# Node API setup
|
||||
# API setup (including client dist)
|
||||
FROM base AS api-build
|
||||
WORKDIR /app
|
||||
COPY api ./api
|
||||
COPY config ./config
|
||||
COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
|
||||
COPY --from=client-build /app/client/dist ./client/dist
|
||||
WORKDIR /app/api
|
||||
COPY api/package*.json ./
|
||||
COPY api/ ./
|
||||
# Copy helper scripts
|
||||
COPY config/ ./
|
||||
# Copy data-provider to API's node_modules
|
||||
COPY --from=data-provider-build /app/packages/data-provider/ /app/api/node_modules/librechat-data-provider/
|
||||
RUN npm install --include prod; npm cache clean --force
|
||||
COPY --from=client-build /app/client/dist /app/client/dist
|
||||
RUN npm prune --production
|
||||
EXPOSE 3080
|
||||
ENV HOST=0.0.0.0
|
||||
CMD ["node", "server/index.js"]
|
||||
|
||||
# Nginx setup
|
||||
FROM nginx:1.27.0-alpine AS prod-stage
|
||||
COPY ./client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -81,7 +81,7 @@ LibreChat brings together the future of assistant AIs with the revolutionary tec
|
||||
|
||||
With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform.
|
||||
|
||||
[](https://www.youtube.com/watch?v=bSVHEbVPNl4)
|
||||
[](https://www.youtube.com/watch?v=cvosUxogdpI)
|
||||
Click on the thumbnail to open the video☝️
|
||||
|
||||
---
|
||||
|
||||
@@ -12,12 +12,13 @@ const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const {
|
||||
truncateText,
|
||||
formatMessage,
|
||||
addCacheControl,
|
||||
titleFunctionPrompt,
|
||||
parseParamFromPrompt,
|
||||
createContextHandlers,
|
||||
} = require('./prompts');
|
||||
const spendTokens = require('~/models/spendTokens');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { getModelMaxTokens, matchModelName } = require('~/utils');
|
||||
const { sleep } = require('~/server/utils');
|
||||
const BaseClient = require('./BaseClient');
|
||||
const { logger } = require('~/config');
|
||||
@@ -32,6 +33,7 @@ function delayBeforeRetry(attempts, baseDelay = 1000) {
|
||||
return new Promise((resolve) => setTimeout(resolve, baseDelay * attempts));
|
||||
}
|
||||
|
||||
const tokenEventTypes = new Set(['message_start', 'message_delta']);
|
||||
const { legacy } = anthropicSettings;
|
||||
|
||||
class AnthropicClient extends BaseClient {
|
||||
@@ -44,6 +46,24 @@ class AnthropicClient extends BaseClient {
|
||||
? options.contextStrategy.toLowerCase()
|
||||
: 'discard';
|
||||
this.setOptions(options);
|
||||
/** @type {string | undefined} */
|
||||
this.systemMessage;
|
||||
/** @type {AnthropicMessageStartEvent| undefined} */
|
||||
this.message_start;
|
||||
/** @type {AnthropicMessageDeltaEvent| undefined} */
|
||||
this.message_delta;
|
||||
/** Whether the model is part of the Claude 3 Family
|
||||
* @type {boolean} */
|
||||
this.isClaude3;
|
||||
/** Whether to use Messages API or Completions API
|
||||
* @type {boolean} */
|
||||
this.useMessages;
|
||||
/** Whether or not the model is limited to the legacy amount of output tokens
|
||||
* @type {boolean} */
|
||||
this.isLegacyOutput;
|
||||
/** Whether or not the model supports Prompt Caching
|
||||
* @type {boolean} */
|
||||
this.supportsCacheControl;
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
@@ -63,14 +83,19 @@ class AnthropicClient extends BaseClient {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
const modelOptions = this.options.modelOptions || {};
|
||||
this.modelOptions = {
|
||||
...modelOptions,
|
||||
model: modelOptions.model || anthropicSettings.model.default,
|
||||
};
|
||||
this.modelOptions = Object.assign(
|
||||
{
|
||||
model: anthropicSettings.model.default,
|
||||
},
|
||||
this.modelOptions,
|
||||
this.options.modelOptions,
|
||||
);
|
||||
|
||||
this.isClaude3 = this.modelOptions.model.includes('claude-3');
|
||||
this.isLegacyOutput = !this.modelOptions.model.includes('claude-3-5-sonnet');
|
||||
const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic);
|
||||
this.isClaude3 = modelMatch.startsWith('claude-3');
|
||||
this.isLegacyOutput = !modelMatch.startsWith('claude-3-5-sonnet');
|
||||
this.supportsCacheControl =
|
||||
this.options.promptCache && this.checkPromptCacheSupport(modelMatch);
|
||||
|
||||
if (
|
||||
this.isLegacyOutput &&
|
||||
@@ -147,19 +172,74 @@ class AnthropicClient extends BaseClient {
|
||||
options.baseURL = this.options.reverseProxyUrl;
|
||||
}
|
||||
|
||||
if (requestOptions?.model && requestOptions.model.includes('claude-3-5-sonnet')) {
|
||||
if (
|
||||
this.supportsCacheControl &&
|
||||
requestOptions?.model &&
|
||||
requestOptions.model.includes('claude-3-5-sonnet')
|
||||
) {
|
||||
options.defaultHeaders = {
|
||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
|
||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
|
||||
};
|
||||
} else if (this.supportsCacheControl) {
|
||||
options.defaultHeaders = {
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
};
|
||||
}
|
||||
|
||||
return new Anthropic(options);
|
||||
}
|
||||
|
||||
getTokenCountForResponse(response) {
|
||||
/**
|
||||
* Get stream usage as returned by this client's API response.
|
||||
* @returns {AnthropicStreamUsage} The stream usage object.
|
||||
*/
|
||||
getStreamUsage() {
|
||||
const inputUsage = this.message_start?.message?.usage ?? {};
|
||||
const outputUsage = this.message_delta?.usage ?? {};
|
||||
return Object.assign({}, inputUsage, outputUsage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the correct token count for the current message based on the token count map and API usage.
|
||||
* Edge case: If the calculation results in a negative value, it returns the original estimate.
|
||||
* If revisiting a conversation with a chat history entirely composed of token estimates,
|
||||
* the cumulative token count going forward should become more accurate as the conversation progresses.
|
||||
* @param {Object} params - The parameters for the calculation.
|
||||
* @param {Record<string, number>} params.tokenCountMap - A map of message IDs to their token counts.
|
||||
* @param {string} params.currentMessageId - The ID of the current message to calculate.
|
||||
* @param {AnthropicStreamUsage} params.usage - The usage object returned by the API.
|
||||
* @returns {number} The correct token count for the current message.
|
||||
*/
|
||||
calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage }) {
|
||||
const originalEstimate = tokenCountMap[currentMessageId] || 0;
|
||||
|
||||
if (!usage || typeof usage.input_tokens !== 'number') {
|
||||
return originalEstimate;
|
||||
}
|
||||
|
||||
tokenCountMap[currentMessageId] = 0;
|
||||
const totalTokensFromMap = Object.values(tokenCountMap).reduce((sum, count) => {
|
||||
const numCount = Number(count);
|
||||
return sum + (isNaN(numCount) ? 0 : numCount);
|
||||
}, 0);
|
||||
const totalInputTokens =
|
||||
(usage.input_tokens ?? 0) +
|
||||
(usage.cache_creation_input_tokens ?? 0) +
|
||||
(usage.cache_read_input_tokens ?? 0);
|
||||
|
||||
const currentMessageTokens = totalInputTokens - totalTokensFromMap;
|
||||
return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Token Count for LibreChat Message
|
||||
* @param {TMessage} responseMessage
|
||||
* @returns {number}
|
||||
*/
|
||||
getTokenCountForResponse(responseMessage) {
|
||||
return this.getTokenCountForMessage({
|
||||
role: 'assistant',
|
||||
content: response.text,
|
||||
content: responseMessage.text,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,7 +292,38 @@ class AnthropicClient extends BaseClient {
|
||||
return files;
|
||||
}
|
||||
|
||||
async recordTokenUsage({ promptTokens, completionTokens, model, context = 'message' }) {
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {number} params.promptTokens
|
||||
* @param {number} params.completionTokens
|
||||
* @param {AnthropicStreamUsage} [params.usage]
|
||||
* @param {string} [params.model]
|
||||
* @param {string} [params.context='message']
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async recordTokenUsage({ promptTokens, completionTokens, usage, model, context = 'message' }) {
|
||||
if (usage != null && usage?.input_tokens != null) {
|
||||
const input = usage.input_tokens ?? 0;
|
||||
const write = usage.cache_creation_input_tokens ?? 0;
|
||||
const read = usage.cache_read_input_tokens ?? 0;
|
||||
|
||||
await spendStructuredTokens(
|
||||
{
|
||||
context,
|
||||
user: this.user,
|
||||
conversationId: this.conversationId,
|
||||
model: model ?? this.modelOptions.model,
|
||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||
},
|
||||
{
|
||||
promptTokens: { input, write, read },
|
||||
completionTokens,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await spendTokens(
|
||||
{
|
||||
context,
|
||||
@@ -560,6 +671,18 @@ class AnthropicClient extends BaseClient {
|
||||
: await client.completions.create(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkPromptCacheSupport(modelName) {
|
||||
const modelMatch = matchModelName(modelName, EModelEndpoint.anthropic);
|
||||
if (modelMatch === 'claude-3-5-sonnet' || modelMatch === 'claude-3-haiku') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async sendCompletion(payload, { onProgress, abortController }) {
|
||||
if (!abortController) {
|
||||
abortController = new AbortController();
|
||||
@@ -606,10 +729,22 @@ class AnthropicClient extends BaseClient {
|
||||
requestOptions.max_tokens_to_sample = maxOutputTokens || 1500;
|
||||
}
|
||||
|
||||
if (this.systemMessage) {
|
||||
if (this.systemMessage && this.supportsCacheControl === true) {
|
||||
requestOptions.system = [
|
||||
{
|
||||
type: 'text',
|
||||
text: this.systemMessage,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
} else if (this.systemMessage) {
|
||||
requestOptions.system = this.systemMessage;
|
||||
}
|
||||
|
||||
if (this.supportsCacheControl === true && this.useMessages) {
|
||||
requestOptions.messages = addCacheControl(requestOptions.messages);
|
||||
}
|
||||
|
||||
logger.debug('[AnthropicClient]', { ...requestOptions });
|
||||
|
||||
const handleChunk = (currentChunk) => {
|
||||
@@ -639,6 +774,11 @@ class AnthropicClient extends BaseClient {
|
||||
|
||||
for await (const completion of response) {
|
||||
// Handle each completion as before
|
||||
const type = completion?.type ?? '';
|
||||
if (tokenEventTypes.has(type)) {
|
||||
logger.debug(`[AnthropicClient] ${type}`, completion);
|
||||
this[type] = completion;
|
||||
}
|
||||
if (completion?.delta?.text) {
|
||||
handleChunk(completion.delta.text);
|
||||
} else if (completion.completion) {
|
||||
@@ -682,6 +822,7 @@ class AnthropicClient extends BaseClient {
|
||||
maxContextTokens: this.options.maxContextTokens,
|
||||
promptPrefix: this.options.promptPrefix,
|
||||
modelLabel: this.options.modelLabel,
|
||||
promptCache: this.options.promptCache,
|
||||
resendFiles: this.options.resendFiles,
|
||||
iconURL: this.options.iconURL,
|
||||
greeting: this.options.greeting,
|
||||
@@ -727,6 +868,8 @@ class AnthropicClient extends BaseClient {
|
||||
*/
|
||||
async titleConvo({ text, responseText = '' }) {
|
||||
let title = 'New Chat';
|
||||
this.message_delta = undefined;
|
||||
this.message_start = undefined;
|
||||
const convo = `<initial_message>
|
||||
${truncateText(text)}
|
||||
</initial_message>
|
||||
|
||||
@@ -54,10 +54,22 @@ class BaseClient {
|
||||
throw new Error('Subclasses attempted to call summarizeMessages without implementing it');
|
||||
}
|
||||
|
||||
async getTokenCountForResponse(response) {
|
||||
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', response);
|
||||
/**
|
||||
* Abstract method to get the token count for a message. Subclasses must implement this method.
|
||||
* @param {TMessage} responseMessage
|
||||
* @returns {number}
|
||||
*/
|
||||
getTokenCountForResponse(responseMessage) {
|
||||
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', responseMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method to record token usage. Subclasses must implement this method.
|
||||
* If a correction to the token usage is needed, the method should return an object with the corrected token counts.
|
||||
* @param {number} promptTokens
|
||||
* @param {number} completionTokens
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async recordTokenUsage({ promptTokens, completionTokens }) {
|
||||
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', {
|
||||
promptTokens,
|
||||
@@ -536,13 +548,31 @@ class BaseClient {
|
||||
this.getTokenCountForResponse &&
|
||||
this.getTokenCount
|
||||
) {
|
||||
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
|
||||
const completionTokens = this.getTokenCount(completion);
|
||||
await this.recordTokenUsage({ promptTokens, completionTokens });
|
||||
let completionTokens;
|
||||
|
||||
/**
|
||||
* Metadata about input/output costs for the current message. The client
|
||||
* should provide a function to get the current stream usage metadata; if not,
|
||||
* use the legacy token estimations.
|
||||
* @type {StreamUsage | null} */
|
||||
const usage = this.getStreamUsage != null ? this.getStreamUsage() : null;
|
||||
|
||||
if (usage != null && Number(usage.output_tokens) > 0) {
|
||||
responseMessage.tokenCount = usage.output_tokens;
|
||||
completionTokens = responseMessage.tokenCount;
|
||||
await this.updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts });
|
||||
} else {
|
||||
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
|
||||
completionTokens = this.getTokenCount(completion);
|
||||
}
|
||||
|
||||
await this.recordTokenUsage({ promptTokens, completionTokens, usage });
|
||||
}
|
||||
|
||||
if (this.userMessagePromise) {
|
||||
await this.userMessagePromise;
|
||||
}
|
||||
|
||||
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
|
||||
const messageCache = getLogStores(CacheKeys.MESSAGES);
|
||||
messageCache.set(
|
||||
@@ -557,6 +587,66 @@ class BaseClient {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream usage should only be used for user message token count re-calculation if:
|
||||
* - The stream usage is available, with input tokens greater than 0,
|
||||
* - the client provides a function to calculate the current token count,
|
||||
* - files are being resent with every message (default behavior; or if `false`, with no attachments),
|
||||
* - the `promptPrefix` (custom instructions) is not set.
|
||||
*
|
||||
* In these cases, the legacy token estimations would be more accurate.
|
||||
*
|
||||
* TODO: included system messages in the `orderedMessages` accounting, potentially as a
|
||||
* separate message in the UI. ChatGPT does this through "hidden" system messages.
|
||||
* @param {object} params
|
||||
* @param {StreamUsage} params.usage
|
||||
* @param {Record<string, number>} params.tokenCountMap
|
||||
* @param {TMessage} params.userMessage
|
||||
* @param {object} params.opts
|
||||
*/
|
||||
async updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts }) {
|
||||
/** @type {boolean} */
|
||||
const shouldUpdateCount =
|
||||
this.calculateCurrentTokenCount != null &&
|
||||
Number(usage.input_tokens) > 0 &&
|
||||
(this.options.resendFiles ||
|
||||
(!this.options.resendFiles && !this.options.attachments?.length)) &&
|
||||
!this.options.promptPrefix;
|
||||
|
||||
if (!shouldUpdateCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessageTokenCount = this.calculateCurrentTokenCount({
|
||||
currentMessageId: userMessage.messageId,
|
||||
tokenCountMap,
|
||||
usage,
|
||||
});
|
||||
|
||||
if (userMessageTokenCount === userMessage.tokenCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
userMessage.tokenCount = userMessageTokenCount;
|
||||
/*
|
||||
Note: `AskController` saves the user message, so we update the count of its `userMessage` reference
|
||||
*/
|
||||
if (typeof opts?.getReqData === 'function') {
|
||||
opts.getReqData({
|
||||
userMessage,
|
||||
});
|
||||
}
|
||||
/*
|
||||
Note: we update the user message to be sure it gets the calculated token count;
|
||||
though `AskController` saves the user message, EditController does not
|
||||
*/
|
||||
await this.userMessagePromise;
|
||||
await this.updateMessageInDatabase({
|
||||
messageId: userMessage.messageId,
|
||||
tokenCount: userMessageTokenCount,
|
||||
});
|
||||
}
|
||||
|
||||
async loadHistory(conversationId, parentMessageId = null) {
|
||||
logger.debug('[BaseClient] Loading history:', { conversationId, parentMessageId });
|
||||
|
||||
@@ -644,6 +734,10 @@ class BaseClient {
|
||||
return { message: savedMessage, conversation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a message in the database.
|
||||
* @param {Partial<TMessage>} message
|
||||
*/
|
||||
async updateMessageInDatabase(message) {
|
||||
await updateMessage(this.options.req, message);
|
||||
}
|
||||
|
||||
@@ -120,19 +120,7 @@ class GoogleClient extends BaseClient {
|
||||
.filter((ex) => ex)
|
||||
.filter((obj) => obj.input.content !== '' && obj.output.content !== '');
|
||||
|
||||
const modelOptions = this.options.modelOptions || {};
|
||||
this.modelOptions = {
|
||||
...modelOptions,
|
||||
// set some good defaults (check for undefined in some cases because they may be 0)
|
||||
model: modelOptions.model || settings.model.default,
|
||||
temperature:
|
||||
typeof modelOptions.temperature === 'undefined'
|
||||
? settings.temperature.default
|
||||
: modelOptions.temperature,
|
||||
topP: typeof modelOptions.topP === 'undefined' ? settings.topP.default : modelOptions.topP,
|
||||
topK: typeof modelOptions.topK === 'undefined' ? settings.topK.default : modelOptions.topK,
|
||||
// stop: modelOptions.stop // no stop method for now
|
||||
};
|
||||
this.modelOptions = this.options.modelOptions || {};
|
||||
|
||||
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
||||
|
||||
@@ -808,7 +796,7 @@ class GoogleClient extends BaseClient {
|
||||
});
|
||||
|
||||
reply = titleResponse.content;
|
||||
|
||||
// TODO: RECORD TOKEN USAGE
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
ImageDetail,
|
||||
EModelEndpoint,
|
||||
resolveHeaders,
|
||||
openAISettings,
|
||||
ImageDetailCost,
|
||||
CohereConstants,
|
||||
getResponseSender,
|
||||
@@ -27,9 +28,9 @@ const {
|
||||
createContextHandlers,
|
||||
} = require('./prompts');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { isEnabled, sleep } = require('~/server/utils');
|
||||
const { handleOpenAIErrors } = require('./tools/util');
|
||||
const spendTokens = require('~/models/spendTokens');
|
||||
const { createLLM, RunManager } = require('./llm');
|
||||
const ChatGPTClient = require('./ChatGPTClient');
|
||||
const { summaryBuffer } = require('./memory');
|
||||
@@ -85,26 +86,13 @@ class OpenAIClient extends BaseClient {
|
||||
this.apiKey = this.options.openaiApiKey;
|
||||
}
|
||||
|
||||
const modelOptions = this.options.modelOptions || {};
|
||||
|
||||
if (!this.modelOptions) {
|
||||
this.modelOptions = {
|
||||
...modelOptions,
|
||||
model: modelOptions.model || 'gpt-3.5-turbo',
|
||||
temperature:
|
||||
typeof modelOptions.temperature === 'undefined' ? 0.8 : modelOptions.temperature,
|
||||
top_p: typeof modelOptions.top_p === 'undefined' ? 1 : modelOptions.top_p,
|
||||
presence_penalty:
|
||||
typeof modelOptions.presence_penalty === 'undefined' ? 1 : modelOptions.presence_penalty,
|
||||
stop: modelOptions.stop,
|
||||
};
|
||||
} else {
|
||||
// Update the modelOptions if it already exists
|
||||
this.modelOptions = {
|
||||
...this.modelOptions,
|
||||
...modelOptions,
|
||||
};
|
||||
}
|
||||
this.modelOptions = Object.assign(
|
||||
{
|
||||
model: openAISettings.model.default,
|
||||
},
|
||||
this.modelOptions,
|
||||
this.options.modelOptions,
|
||||
);
|
||||
|
||||
this.defaultVisionModel = this.options.visionModel ?? 'gpt-4-vision-preview';
|
||||
if (typeof this.options.attachments?.then === 'function') {
|
||||
@@ -827,7 +815,7 @@ class OpenAIClient extends BaseClient {
|
||||
|
||||
const instructionsPayload = [
|
||||
{
|
||||
role: this.options.titleMessageRole ?? 'system',
|
||||
role: this.options.titleMessageRole ?? (this.isOllama ? 'user' : 'system'),
|
||||
content: `Please generate ${titleInstruction}
|
||||
|
||||
${convo}
|
||||
@@ -1194,7 +1182,15 @@ ${convo}
|
||||
}
|
||||
|
||||
let UnexpectedRoleError = false;
|
||||
/** @type {Promise<void>} */
|
||||
let streamPromise;
|
||||
/** @type {(value: void | PromiseLike<void>) => void} */
|
||||
let streamResolve;
|
||||
|
||||
if (modelOptions.stream) {
|
||||
streamPromise = new Promise((resolve) => {
|
||||
streamResolve = resolve;
|
||||
});
|
||||
const stream = await openai.beta.chat.completions
|
||||
.stream({
|
||||
...modelOptions,
|
||||
@@ -1206,13 +1202,17 @@ ${convo}
|
||||
.on('error', (err) => {
|
||||
handleOpenAIErrors(err, errorCallback, 'stream');
|
||||
})
|
||||
.on('finalChatCompletion', (finalChatCompletion) => {
|
||||
.on('finalChatCompletion', async (finalChatCompletion) => {
|
||||
const finalMessage = finalChatCompletion?.choices?.[0]?.message;
|
||||
if (finalMessage && finalMessage?.role !== 'assistant') {
|
||||
if (!finalMessage) {
|
||||
return;
|
||||
}
|
||||
await streamPromise;
|
||||
if (finalMessage?.role !== 'assistant') {
|
||||
finalChatCompletion.choices[0].message.role = 'assistant';
|
||||
}
|
||||
|
||||
if (finalMessage && !finalMessage?.content?.trim()) {
|
||||
if (typeof finalMessage.content !== 'string' || finalMessage.content.trim() === '') {
|
||||
finalChatCompletion.choices[0].message.content = intermediateReply;
|
||||
}
|
||||
})
|
||||
@@ -1235,6 +1235,8 @@ ${convo}
|
||||
await sleep(streamRate);
|
||||
}
|
||||
|
||||
streamResolve();
|
||||
|
||||
if (!UnexpectedRoleError) {
|
||||
chatCompletion = await stream.finalChatCompletion().catch((err) => {
|
||||
handleOpenAIErrors(err, errorCallback, 'finalChatCompletion');
|
||||
@@ -1262,14 +1264,23 @@ ${convo}
|
||||
throw new Error('Chat completion failed');
|
||||
}
|
||||
|
||||
const { message, finish_reason } = chatCompletion.choices[0];
|
||||
if (chatCompletion) {
|
||||
this.metadata = { finish_reason };
|
||||
const { choices } = chatCompletion;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
logger.warn('[OpenAIClient] Chat completion response has no choices');
|
||||
return intermediateReply;
|
||||
}
|
||||
|
||||
const { message, finish_reason } = choices[0] ?? {};
|
||||
this.metadata = { finish_reason };
|
||||
|
||||
logger.debug('[OpenAIClient] chatCompletion response', chatCompletion);
|
||||
|
||||
if (!message?.content?.trim() && intermediateReply.length) {
|
||||
if (!message) {
|
||||
logger.warn('[OpenAIClient] Message is undefined in chatCompletion response');
|
||||
return intermediateReply;
|
||||
}
|
||||
|
||||
if (typeof message.content !== 'string' || message.content.trim() === '') {
|
||||
logger.debug(
|
||||
'[OpenAIClient] chatCompletion: using intermediateReply due to empty message.content',
|
||||
{ intermediateReply },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { createStartHandler } = require('~/app/clients/callbacks');
|
||||
const spendTokens = require('~/models/spendTokens');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
class RunManager {
|
||||
|
||||
43
api/app/clients/prompts/addCacheControl.js
Normal file
43
api/app/clients/prompts/addCacheControl.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Anthropic API: Adds cache control to the appropriate user messages in the payload.
|
||||
* @param {Array<AnthropicMessage>} messages - The array of message objects.
|
||||
* @returns {Array<AnthropicMessage>} - The updated array of message objects with cache control added.
|
||||
*/
|
||||
function addCacheControl(messages) {
|
||||
if (!Array.isArray(messages) || messages.length < 2) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const updatedMessages = [...messages];
|
||||
let userMessagesModified = 0;
|
||||
|
||||
for (let i = updatedMessages.length - 1; i >= 0 && userMessagesModified < 2; i--) {
|
||||
const message = updatedMessages[i];
|
||||
if (message.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
message.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: message.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
userMessagesModified++;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (let j = message.content.length - 1; j >= 0; j--) {
|
||||
if (message.content[j].type === 'text') {
|
||||
message.content[j].cache_control = { type: 'ephemeral' };
|
||||
userMessagesModified++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedMessages;
|
||||
}
|
||||
|
||||
module.exports = addCacheControl;
|
||||
227
api/app/clients/prompts/addCacheControl.spec.js
Normal file
227
api/app/clients/prompts/addCacheControl.spec.js
Normal file
@@ -0,0 +1,227 @@
|
||||
const addCacheControl = require('./addCacheControl');
|
||||
|
||||
describe('addCacheControl', () => {
|
||||
test('should add cache control to the last two user messages with array content', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'I\'m doing well, thanks!' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Great!' }] },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content[0]).not.toHaveProperty('cache_control');
|
||||
expect(result[2].content[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
expect(result[4].content[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
test('should add cache control to the last two user messages with string content', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: 'How are you?' },
|
||||
{ role: 'assistant', content: 'I\'m doing well, thanks!' },
|
||||
{ role: 'user', content: 'Great!' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content).toBe('Hello');
|
||||
expect(result[2].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'How are you?',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
expect(result[4].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Great!',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle mixed string and array content', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
expect(result[2].content[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
test('should handle less than two user messages', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
expect(result[1].content).toBe('Hi there');
|
||||
});
|
||||
|
||||
test('should return original array if no user messages', () => {
|
||||
const messages = [
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'assistant', content: 'How can I help?' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result).toEqual(messages);
|
||||
});
|
||||
|
||||
test('should handle empty array', () => {
|
||||
const messages = [];
|
||||
const result = addCacheControl(messages);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle non-array input', () => {
|
||||
const messages = 'not an array';
|
||||
const result = addCacheControl(messages);
|
||||
expect(result).toBe('not an array');
|
||||
});
|
||||
|
||||
test('should not modify assistant messages', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: 'How are you?' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[1].content).toBe('Hi there');
|
||||
});
|
||||
|
||||
test('should handle multiple content items in user messages', () => {
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image', url: 'http://example.com/image.jpg' },
|
||||
{ type: 'text', text: 'This is an image' },
|
||||
],
|
||||
},
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: 'How are you?' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content[0]).not.toHaveProperty('cache_control');
|
||||
expect(result[0].content[1]).not.toHaveProperty('cache_control');
|
||||
expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' });
|
||||
expect(result[2].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'How are you?',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle an array with mixed content types', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
|
||||
{ role: 'assistant', content: 'I\'m doing well, thanks!' },
|
||||
{ role: 'user', content: 'Great!' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content).toEqual('Hello');
|
||||
expect(result[2].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'How are you?',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
expect(result[4].content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Great!',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
expect(result[1].content).toBe('Hi there');
|
||||
expect(result[3].content).toBe('I\'m doing well, thanks!');
|
||||
});
|
||||
|
||||
test('should handle edge case with multiple content types', () => {
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'some_base64_string' },
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'another_base64_string' },
|
||||
},
|
||||
{ type: 'text', text: 'what do all these images have in common' },
|
||||
],
|
||||
},
|
||||
{ role: 'assistant', content: 'I see multiple images.' },
|
||||
{ role: 'user', content: 'Correct!' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content[0]).not.toHaveProperty('cache_control');
|
||||
expect(result[0].content[1]).not.toHaveProperty('cache_control');
|
||||
expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' });
|
||||
expect(result[2].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Correct!',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle user message with no text block', () => {
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'some_base64_string' },
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'another_base64_string' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'assistant', content: 'I see two images.' },
|
||||
{ role: 'user', content: 'Correct!' },
|
||||
];
|
||||
|
||||
const result = addCacheControl(messages);
|
||||
|
||||
expect(result[0].content[0]).not.toHaveProperty('cache_control');
|
||||
expect(result[0].content[1]).not.toHaveProperty('cache_control');
|
||||
expect(result[2].content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Correct!',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
});
|
||||
});
|
||||
});
|
||||
162
api/app/clients/prompts/artifacts.js
Normal file
162
api/app/clients/prompts/artifacts.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const artifactsPrompt = `The assistant can create and reference artifacts during conversations.
|
||||
|
||||
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
|
||||
|
||||
# Good artifacts are...
|
||||
- Substantial content (>15 lines)
|
||||
- Content that the user is likely to modify, iterate on, or take ownership of
|
||||
- Self-contained, complex content that can be understood on its own, without context from the conversation
|
||||
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
|
||||
- Content likely to be referenced or reused multiple times
|
||||
|
||||
# Don't use artifacts for...
|
||||
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
|
||||
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
|
||||
- Suggestions, commentary, or feedback on existing artifacts
|
||||
- Conversational or explanatory content that doesn't represent a standalone piece of work
|
||||
- Content that is dependent on the current conversational context to be useful
|
||||
- Content that is unlikely to be modified or iterated upon by the user
|
||||
- Request from users that appears to be a one-off question
|
||||
|
||||
# Usage notes
|
||||
- One artifact per message unless specifically requested
|
||||
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
|
||||
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
|
||||
- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
|
||||
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
|
||||
- Always provide complete, specific, and fully functional content without any placeholders, ellipses, or 'remains the same' comments.
|
||||
|
||||
<artifact_instructions>
|
||||
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
|
||||
|
||||
1. Create the artifact using the following format:
|
||||
|
||||
:::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
|
||||
\`\`\`
|
||||
Your artifact content here
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
|
||||
3. Include a \`title\` attribute to provide a brief title or description of the content.
|
||||
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
|
||||
- Code: "application/vnd.code"
|
||||
- Use for code snippets or scripts in any programming language.
|
||||
- Include the language name as the value of the \`language\` attribute (e.g., \`language="python"\`).
|
||||
- Documents: "text/markdown"
|
||||
- Plain text, Markdown, or other formatted text documents
|
||||
- HTML: "text/html"
|
||||
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
|
||||
- It is inappropriate to use "text/html" when sharing snippets, code samples & example HTML or CSS code, as it would be rendered as a webpage and the source code would be obscured. The assistant should instead use "application/vnd.code" defined above.
|
||||
- If the assistant is unable to follow the above requirements for any reason, use "application/vnd.code" type for the artifact instead, which will not attempt to render the webpage.
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||
- React Components: "application/vnd.react"
|
||||
- Use this for displaying either: React elements, e.g. \`<strong>Hello World!</strong>\`, React pure functional components, e.g. \`() => <strong>Hello World!</strong>\`, React functional components with Hooks, or React component classes
|
||||
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
|
||||
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
|
||||
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
|
||||
- The lucide-react@0.263.1 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \`<Camera color="red" size={48} />\`
|
||||
- The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \`<LineChart ...><XAxis dataKey="name"> ...\`
|
||||
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
|
||||
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
|
||||
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- If you are unable to follow the above requirements for any reason, use "application/vnd.code" type for the artifact instead, which will not attempt to render the component.
|
||||
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
|
||||
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
|
||||
7. Always use triple backticks (\`\`\`) to enclose the content within the artifact, regardless of the content type.
|
||||
</artifact_instructions>
|
||||
|
||||
Here are some examples of correct usage of artifacts:
|
||||
|
||||
<examples>
|
||||
<example_docstring>
|
||||
This example demonstrates how to create a new artifact for a Python script.
|
||||
</example_docstring>
|
||||
|
||||
<example>
|
||||
<user_query>Can you help me create a Python script to calculate the factorial of a number?</user_query>
|
||||
|
||||
<assistant_response>
|
||||
Sure! Here's a Python script that calculates the factorial of a number:
|
||||
|
||||
:::artifact{identifier="factorial-script" type="application/vnd.code" language="python" title="Simple Python factorial script"}
|
||||
\`\`\`python
|
||||
def factorial(n):
|
||||
if n == 0:
|
||||
return 1
|
||||
else:
|
||||
return n * factorial(n - 1)
|
||||
|
||||
# Get user input
|
||||
num = int(input("Enter a non-negative integer: "))
|
||||
|
||||
# Calculate and print the factorial
|
||||
result = factorial(num)
|
||||
print(f"The factorial of {num} is {result}")
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This script defines a \`factorial\` function using recursion to calculate the factorial of a given number. It then prompts the user for input, calculates the factorial, and prints the result.
|
||||
|
||||
To use this script:
|
||||
1. Copy the code into a .py file
|
||||
2. Run the script
|
||||
3. Enter a non-negative integer when prompted
|
||||
4. The script will output the factorial of the number you entered
|
||||
|
||||
Let me know if you have any questions or if you'd like to modify the script in any way!
|
||||
</assistant_response>
|
||||
</example>
|
||||
|
||||
<example_docstring>
|
||||
This example demonstrates how to create a Mermaid artifact for a simple flow chart.
|
||||
</example_docstring>
|
||||
|
||||
<example>
|
||||
<user_query>Can you create a simple flow chart showing the process of making tea using Mermaid?</user_query>
|
||||
|
||||
<assistant_response>
|
||||
Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
|
||||
|
||||
:::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
A[Start] --> B{Water boiled?}
|
||||
B -->|Yes| C[Add tea leaves to cup]
|
||||
B -->|No| D[Boil water]
|
||||
D --> B
|
||||
C --> E[Pour boiling water into cup]
|
||||
E --> F[Steep tea for desired time]
|
||||
F --> G[Remove tea leaves]
|
||||
G --> H[Add milk or sugar, if desired]
|
||||
H --> I[Enjoy your tea!]
|
||||
I --> J[End]
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
|
||||
|
||||
1. Start
|
||||
2. Check if water is boiled
|
||||
3. If not boiled, boil the water
|
||||
4. Once water is boiled, add tea leaves to the cup
|
||||
5. Pour boiling water into the cup
|
||||
6. Steep the tea for the desired time
|
||||
7. Remove the tea leaves
|
||||
8. Optionally add milk or sugar
|
||||
9. Enjoy your tea!
|
||||
10. End
|
||||
|
||||
This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
|
||||
</assistant_response>
|
||||
</example>
|
||||
</examples>`;
|
||||
|
||||
module.exports = artifactsPrompt;
|
||||
@@ -1,3 +1,4 @@
|
||||
const addCacheControl = require('./addCacheControl');
|
||||
const formatMessages = require('./formatMessages');
|
||||
const summaryPrompts = require('./summaryPrompts');
|
||||
const handleInputs = require('./handleInputs');
|
||||
@@ -8,6 +9,7 @@ const createVisionPrompt = require('./createVisionPrompt');
|
||||
const createContextHandlers = require('./createContextHandlers');
|
||||
|
||||
module.exports = {
|
||||
addCacheControl,
|
||||
...formatMessages,
|
||||
...summaryPrompts,
|
||||
...handleInputs,
|
||||
|
||||
@@ -206,12 +206,26 @@ describe('AnthropicClient', () => {
|
||||
const modelOptions = {
|
||||
model: 'claude-3-5-sonnet-20240307',
|
||||
};
|
||||
client.setOptions({ modelOptions });
|
||||
client.setOptions({ modelOptions, promptCache: true });
|
||||
const anthropicClient = client.getClient(modelOptions);
|
||||
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||
'max-tokens-3-5-sonnet-2024-07-15',
|
||||
'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add beta header for claude-3-haiku model', () => {
|
||||
const client = new AnthropicClient('test-api-key');
|
||||
const modelOptions = {
|
||||
model: 'claude-3-haiku-2028',
|
||||
};
|
||||
client.setOptions({ modelOptions, promptCache: true });
|
||||
const anthropicClient = client.getClient(modelOptions);
|
||||
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -226,4 +240,145 @@ describe('AnthropicClient', () => {
|
||||
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCurrentTokenCount', () => {
|
||||
let client;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new AnthropicClient('test-api-key');
|
||||
});
|
||||
|
||||
it('should calculate correct token count when usage is provided', () => {
|
||||
const tokenCountMap = {
|
||||
msg1: 10,
|
||||
msg2: 20,
|
||||
currentMsg: 30,
|
||||
};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = {
|
||||
input_tokens: 70,
|
||||
output_tokens: 50,
|
||||
};
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(40); // 70 - (10 + 20) = 40
|
||||
});
|
||||
|
||||
it('should return original estimate if calculation results in negative value', () => {
|
||||
const tokenCountMap = {
|
||||
msg1: 40,
|
||||
msg2: 50,
|
||||
currentMsg: 30,
|
||||
};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = {
|
||||
input_tokens: 80,
|
||||
output_tokens: 50,
|
||||
};
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(30); // Original estimate
|
||||
});
|
||||
|
||||
it('should handle cache creation and read input tokens', () => {
|
||||
const tokenCountMap = {
|
||||
msg1: 10,
|
||||
msg2: 20,
|
||||
currentMsg: 30,
|
||||
};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = {
|
||||
input_tokens: 50,
|
||||
cache_creation_input_tokens: 10,
|
||||
cache_read_input_tokens: 20,
|
||||
output_tokens: 40,
|
||||
};
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(50); // (50 + 10 + 20) - (10 + 20) = 50
|
||||
});
|
||||
|
||||
it('should handle missing usage properties', () => {
|
||||
const tokenCountMap = {
|
||||
msg1: 10,
|
||||
msg2: 20,
|
||||
currentMsg: 30,
|
||||
};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = {
|
||||
output_tokens: 40,
|
||||
};
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(30); // Original estimate
|
||||
});
|
||||
|
||||
it('should handle empty tokenCountMap', () => {
|
||||
const tokenCountMap = {};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = {
|
||||
input_tokens: 50,
|
||||
output_tokens: 40,
|
||||
};
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(50);
|
||||
expect(Number.isNaN(result)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle zero values in usage', () => {
|
||||
const tokenCountMap = {
|
||||
msg1: 10,
|
||||
currentMsg: 20,
|
||||
};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(20); // Should return original estimate
|
||||
expect(Number.isNaN(result)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined usage', () => {
|
||||
const tokenCountMap = {
|
||||
msg1: 10,
|
||||
currentMsg: 20,
|
||||
};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = undefined;
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(20); // Should return original estimate
|
||||
expect(Number.isNaN(result)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle non-numeric values in tokenCountMap', () => {
|
||||
const tokenCountMap = {
|
||||
msg1: 'ten',
|
||||
currentMsg: 20,
|
||||
};
|
||||
const currentMessageId = 'currentMsg';
|
||||
const usage = {
|
||||
input_tokens: 30,
|
||||
output_tokens: 10,
|
||||
};
|
||||
|
||||
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
|
||||
|
||||
expect(result).toBe(30); // Should return 30 (input_tokens) - 0 (ignored 'ten') = 30
|
||||
expect(Number.isNaN(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
2
api/cache/clearPendingReq.js
vendored
2
api/cache/clearPendingReq.js
vendored
@@ -35,7 +35,7 @@ const clearPendingReq = async ({ userId, cache: _cache }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${USE_REDIS ? namespace : ''}:${userId ?? ''}`;
|
||||
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId ?? ''}`;
|
||||
const currentReq = +((await cache.get(key)) ?? 0);
|
||||
|
||||
if (currentReq && currentReq >= 1) {
|
||||
|
||||
@@ -133,14 +133,21 @@ const adjustPositions = async (user, oldPosition, newPosition) => {
|
||||
}
|
||||
|
||||
const update = oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } };
|
||||
const position =
|
||||
oldPosition < newPosition
|
||||
? {
|
||||
$gt: Math.min(oldPosition, newPosition),
|
||||
$lte: Math.max(oldPosition, newPosition),
|
||||
}
|
||||
: {
|
||||
$gte: Math.min(oldPosition, newPosition),
|
||||
$lt: Math.max(oldPosition, newPosition),
|
||||
};
|
||||
|
||||
await ConversationTag.updateMany(
|
||||
{
|
||||
user,
|
||||
position: {
|
||||
$gt: Math.min(oldPosition, newPosition),
|
||||
$lte: Math.max(oldPosition, newPosition),
|
||||
},
|
||||
position,
|
||||
},
|
||||
update,
|
||||
);
|
||||
|
||||
@@ -212,8 +212,8 @@ async function updateMessageText(req, { messageId, text }) {
|
||||
*
|
||||
* @async
|
||||
* @function updateMessage
|
||||
* @param {Object} message - The message object containing update data.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {Object} message - The message object containing update data.
|
||||
* @param {string} message.messageId - The unique identifier for the message.
|
||||
* @param {string} [message.text] - The new text content of the message.
|
||||
* @param {Object[]} [message.files] - The files associated with the message.
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
const { SystemRoles, CacheKeys, roleDefaults } = require('librechat-data-provider');
|
||||
const {
|
||||
CacheKeys,
|
||||
SystemRoles,
|
||||
roleDefaults,
|
||||
PermissionTypes,
|
||||
removeNullishValues,
|
||||
promptPermissionsSchema,
|
||||
bookmarkPermissionsSchema,
|
||||
} = require('librechat-data-provider');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const Role = require('~/models/schema/roleSchema');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Retrieve a role by name and convert the found role document to a plain object.
|
||||
@@ -61,6 +70,63 @@ const updateRoleByName = async function (roleName, updates) {
|
||||
}
|
||||
};
|
||||
|
||||
const permissionSchemas = {
|
||||
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
|
||||
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates access permissions for a specific role and multiple permission types.
|
||||
* @param {SystemRoles} roleName - The role to update.
|
||||
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
|
||||
*/
|
||||
async function updateAccessPermissions(roleName, permissionsUpdate) {
|
||||
const updates = {};
|
||||
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
|
||||
if (permissionSchemas[permissionType]) {
|
||||
updates[permissionType] = removeNullishValues(permissions);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await getRoleByName(roleName);
|
||||
if (!role) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPermissions = {};
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [permissionType, permissions] of Object.entries(updates)) {
|
||||
const currentPermissions = role[permissionType] || {};
|
||||
updatedPermissions[permissionType] = { ...currentPermissions };
|
||||
|
||||
for (const [permission, value] of Object.entries(permissions)) {
|
||||
if (currentPermissions[permission] !== value) {
|
||||
updatedPermissions[permissionType][permission] = value;
|
||||
hasChanges = true;
|
||||
logger.info(
|
||||
`Updating '${roleName}' role ${permissionType} '${permission}' permission from ${currentPermissions[permission]} to: ${value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
await updateRoleByName(roleName, updatedPermissions);
|
||||
logger.info(`Updated '${roleName}' role permissions`);
|
||||
} else {
|
||||
logger.info(`No changes needed for '${roleName}' role permissions`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update ${roleName} role permissions:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default roles in the system.
|
||||
* Creates the default roles (ADMIN, USER) if they don't exist in the database.
|
||||
@@ -83,4 +149,5 @@ module.exports = {
|
||||
getRoleByName,
|
||||
initializeRoles,
|
||||
updateRoleByName,
|
||||
updateAccessPermissions,
|
||||
};
|
||||
|
||||
197
api/models/Role.spec.js
Normal file
197
api/models/Role.spec.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { SystemRoles, PermissionTypes } = require('librechat-data-provider');
|
||||
const Role = require('~/models/schema/roleSchema');
|
||||
const { updateAccessPermissions } = require('~/models/Role');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
// Mock the cache
|
||||
jest.mock('~/cache/getLogStores', () => {
|
||||
return jest.fn().mockReturnValue({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await Role.deleteMany({});
|
||||
getLogStores.mockClear();
|
||||
});
|
||||
|
||||
describe('updateAccessPermissions', () => {
|
||||
it('should update permissions when changes are needed', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
}).save();
|
||||
|
||||
await updateAccessPermissions(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: true,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update permissions when no changes are needed', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
}).save();
|
||||
|
||||
await updateAccessPermissions(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-existent roles', async () => {
|
||||
await updateAccessPermissions('NON_EXISTENT_ROLE', {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
},
|
||||
});
|
||||
|
||||
const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
|
||||
expect(role).toBeNull();
|
||||
});
|
||||
|
||||
it('should update only specified permissions', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
}).save();
|
||||
|
||||
await updateAccessPermissions(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
SHARED_GLOBAL: true,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial updates', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
}).save();
|
||||
|
||||
await updateAccessPermissions(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
USE: false,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
||||
CREATE: true,
|
||||
USE: false,
|
||||
SHARED_GLOBAL: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update multiple permission types at once', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: {
|
||||
USE: true,
|
||||
},
|
||||
}).save();
|
||||
|
||||
await updateAccessPermissions(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
|
||||
[PermissionTypes.BOOKMARKS]: { USE: false },
|
||||
});
|
||||
|
||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
||||
CREATE: true,
|
||||
USE: false,
|
||||
SHARED_GLOBAL: true,
|
||||
});
|
||||
expect(updatedRole[PermissionTypes.BOOKMARKS]).toEqual({
|
||||
USE: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle updates for a single permission type', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
CREATE: true,
|
||||
USE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
}).save();
|
||||
|
||||
await updateAccessPermissions(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
|
||||
});
|
||||
|
||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
||||
CREATE: true,
|
||||
USE: false,
|
||||
SHARED_GLOBAL: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
117
api/models/Token.js
Normal file
117
api/models/Token.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const tokenSchema = require('./schema/tokenSchema');
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Token model.
|
||||
* @type {mongoose.Model}
|
||||
*/
|
||||
const Token = mongoose.model('Token', tokenSchema);
|
||||
|
||||
/**
|
||||
* Creates a new Token instance.
|
||||
* @param {Object} tokenData - The data for the new Token.
|
||||
* @param {mongoose.Types.ObjectId} tokenData.userId - The user's ID. It is required.
|
||||
* @param {String} tokenData.email - The user's email.
|
||||
* @param {String} tokenData.token - The token. It is required.
|
||||
* @param {Number} tokenData.expiresIn - The number of seconds until the token expires.
|
||||
* @returns {Promise<mongoose.Document>} The new Token instance.
|
||||
* @throws Will throw an error if token creation fails.
|
||||
*/
|
||||
async function createToken(tokenData) {
|
||||
try {
|
||||
const currentTime = new Date();
|
||||
const expiresAt = new Date(currentTime.getTime() + tokenData.expiresIn * 1000);
|
||||
|
||||
const newTokenData = {
|
||||
...tokenData,
|
||||
createdAt: currentTime,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
const newToken = new Token(newTokenData);
|
||||
return await newToken.save();
|
||||
} catch (error) {
|
||||
logger.debug('An error occurred while creating token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a Token document that matches the provided query.
|
||||
* @param {Object} query - The query to match against.
|
||||
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
|
||||
* @param {String} query.token - The token value.
|
||||
* @param {String} query.email - The email of the user.
|
||||
* @returns {Promise<Object|null>} The matched Token document, or null if not found.
|
||||
* @throws Will throw an error if the find operation fails.
|
||||
*/
|
||||
async function findToken(query) {
|
||||
try {
|
||||
const conditions = [];
|
||||
|
||||
if (query.userId) {
|
||||
conditions.push({ userId: query.userId });
|
||||
}
|
||||
if (query.token) {
|
||||
conditions.push({ token: query.token });
|
||||
}
|
||||
if (query.email) {
|
||||
conditions.push({ email: query.email });
|
||||
}
|
||||
|
||||
const token = await Token.findOne({
|
||||
$and: conditions,
|
||||
}).lean();
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.debug('An error occurred while finding token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a Token document that matches the provided query.
|
||||
* @param {Object} query - The query to match against.
|
||||
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
|
||||
* @param {String} query.token - The token value.
|
||||
* @param {Object} updateData - The data to update the Token with.
|
||||
* @returns {Promise<mongoose.Document|null>} The updated Token document, or null if not found.
|
||||
* @throws Will throw an error if the update operation fails.
|
||||
*/
|
||||
async function updateToken(query, updateData) {
|
||||
try {
|
||||
return await Token.findOneAndUpdate(query, updateData, { new: true });
|
||||
} catch (error) {
|
||||
logger.debug('An error occurred while updating token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all Token documents that match the provided token, user ID, or email.
|
||||
* @param {Object} query - The query to match against.
|
||||
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
|
||||
* @param {String} query.token - The token value.
|
||||
* @param {String} query.email - The email of the user.
|
||||
* @returns {Promise<Object>} The result of the delete operation.
|
||||
* @throws Will throw an error if the delete operation fails.
|
||||
*/
|
||||
async function deleteTokens(query) {
|
||||
try {
|
||||
return await Token.deleteMany({
|
||||
$or: [{ userId: query.userId }, { token: query.token }, { email: query.email }],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.debug('An error occurred while deleting tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createToken,
|
||||
findToken,
|
||||
updateToken,
|
||||
deleteTokens,
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { isEnabled } = require('../server/utils/handleText');
|
||||
const { isEnabled } = require('~/server/utils/handleText');
|
||||
const transactionSchema = require('./schema/transaction');
|
||||
const { getMultiplier } = require('./tx');
|
||||
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
||||
const { logger } = require('~/config');
|
||||
const Balance = require('./Balance');
|
||||
const cancelRate = 1.15;
|
||||
|
||||
// Method to calculate and set the tokenValue for a transaction
|
||||
/** Method to calculate and set the tokenValue for a transaction */
|
||||
transactionSchema.methods.calculateTokenValue = function () {
|
||||
if (!this.valueKey || !this.tokenType) {
|
||||
this.tokenValue = this.rawAmount;
|
||||
@@ -21,15 +21,17 @@ transactionSchema.methods.calculateTokenValue = function () {
|
||||
}
|
||||
};
|
||||
|
||||
// Static method to create a transaction and update the balance
|
||||
transactionSchema.statics.create = async function (transactionData) {
|
||||
/**
|
||||
* Static method to create a transaction and update the balance
|
||||
* @param {txData} txData - Transaction data.
|
||||
*/
|
||||
transactionSchema.statics.create = async function (txData) {
|
||||
const Transaction = this;
|
||||
|
||||
const transaction = new Transaction(transactionData);
|
||||
transaction.endpointTokenConfig = transactionData.endpointTokenConfig;
|
||||
const transaction = new Transaction(txData);
|
||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||
transaction.calculateTokenValue();
|
||||
|
||||
// Save the transaction
|
||||
await transaction.save();
|
||||
|
||||
if (!isEnabled(process.env.CHECK_BALANCE)) {
|
||||
@@ -57,6 +59,109 @@ transactionSchema.statics.create = async function (transactionData) {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Static method to create a structured transaction and update the balance
|
||||
* @param {txData} txData - Transaction data.
|
||||
*/
|
||||
transactionSchema.statics.createStructured = async function (txData) {
|
||||
const Transaction = this;
|
||||
|
||||
const transaction = new Transaction({
|
||||
...txData,
|
||||
endpointTokenConfig: txData.endpointTokenConfig,
|
||||
});
|
||||
|
||||
transaction.calculateStructuredTokenValue();
|
||||
|
||||
await transaction.save();
|
||||
|
||||
if (!isEnabled(process.env.CHECK_BALANCE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let balance = await Balance.findOne({ user: transaction.user }).lean();
|
||||
let incrementValue = transaction.tokenValue;
|
||||
|
||||
if (balance && balance?.tokenCredits + incrementValue < 0) {
|
||||
incrementValue = -balance.tokenCredits;
|
||||
}
|
||||
|
||||
balance = await Balance.findOneAndUpdate(
|
||||
{ user: transaction.user },
|
||||
{ $inc: { tokenCredits: incrementValue } },
|
||||
{ upsert: true, new: true },
|
||||
).lean();
|
||||
|
||||
return {
|
||||
rate: transaction.rate,
|
||||
user: transaction.user.toString(),
|
||||
balance: balance.tokenCredits,
|
||||
[transaction.tokenType]: incrementValue,
|
||||
};
|
||||
};
|
||||
|
||||
/** Method to calculate token value for structured tokens */
|
||||
transactionSchema.methods.calculateStructuredTokenValue = function () {
|
||||
if (!this.tokenType) {
|
||||
this.tokenValue = this.rawAmount;
|
||||
return;
|
||||
}
|
||||
|
||||
const { model, endpointTokenConfig } = this;
|
||||
|
||||
if (this.tokenType === 'prompt') {
|
||||
const inputMultiplier = getMultiplier({ tokenType: 'prompt', model, endpointTokenConfig });
|
||||
const writeMultiplier =
|
||||
getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier;
|
||||
const readMultiplier =
|
||||
getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? inputMultiplier;
|
||||
|
||||
this.rateDetail = {
|
||||
input: inputMultiplier,
|
||||
write: writeMultiplier,
|
||||
read: readMultiplier,
|
||||
};
|
||||
|
||||
const totalPromptTokens =
|
||||
Math.abs(this.inputTokens || 0) +
|
||||
Math.abs(this.writeTokens || 0) +
|
||||
Math.abs(this.readTokens || 0);
|
||||
|
||||
if (totalPromptTokens > 0) {
|
||||
this.rate =
|
||||
(Math.abs(inputMultiplier * (this.inputTokens || 0)) +
|
||||
Math.abs(writeMultiplier * (this.writeTokens || 0)) +
|
||||
Math.abs(readMultiplier * (this.readTokens || 0))) /
|
||||
totalPromptTokens;
|
||||
} else {
|
||||
this.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens
|
||||
}
|
||||
|
||||
this.tokenValue = -(
|
||||
Math.abs(this.inputTokens || 0) * inputMultiplier +
|
||||
Math.abs(this.writeTokens || 0) * writeMultiplier +
|
||||
Math.abs(this.readTokens || 0) * readMultiplier
|
||||
);
|
||||
|
||||
this.rawAmount = -totalPromptTokens;
|
||||
} else if (this.tokenType === 'completion') {
|
||||
const multiplier = getMultiplier({ tokenType: this.tokenType, model, endpointTokenConfig });
|
||||
this.rate = Math.abs(multiplier);
|
||||
this.tokenValue = -Math.abs(this.rawAmount) * multiplier;
|
||||
this.rawAmount = -Math.abs(this.rawAmount);
|
||||
}
|
||||
|
||||
if (this.context && this.tokenType === 'completion' && this.context === 'incomplete') {
|
||||
this.tokenValue = Math.ceil(this.tokenValue * cancelRate);
|
||||
this.rate *= cancelRate;
|
||||
if (this.rateDetail) {
|
||||
this.rateDetail = Object.fromEntries(
|
||||
Object.entries(this.rateDetail).map(([k, v]) => [k, v * cancelRate]),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Transaction = mongoose.model('Transaction', transactionSchema);
|
||||
|
||||
/**
|
||||
|
||||
348
api/models/Transaction.spec.js
Normal file
348
api/models/Transaction.spec.js
Normal file
@@ -0,0 +1,348 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const Balance = require('./Balance');
|
||||
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
||||
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
||||
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
});
|
||||
|
||||
describe('Regular Token Spending Tests', () => {
|
||||
test('Balance should decrease when spending tokens with spendTokens', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000; // $10.00
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
};
|
||||
|
||||
const tokenUsage = {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
};
|
||||
|
||||
// Act
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
await spendTokens(txData, tokenUsage);
|
||||
|
||||
// Assert
|
||||
console.log('Initial Balance:', initialBalance);
|
||||
|
||||
const updatedBalance = await Balance.findOne({ user: userId });
|
||||
console.log('Updated Balance:', updatedBalance.tokenCredits);
|
||||
|
||||
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
||||
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
|
||||
|
||||
const expectedPromptCost = tokenUsage.promptTokens * promptMultiplier;
|
||||
const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier;
|
||||
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
|
||||
const expectedBalance = initialBalance - expectedTotalCost;
|
||||
|
||||
expect(updatedBalance.tokenCredits).toBeLessThan(initialBalance);
|
||||
expect(updatedBalance.tokenCredits).toBeCloseTo(expectedBalance, 0);
|
||||
|
||||
console.log('Expected Total Cost:', expectedTotalCost);
|
||||
console.log('Actual Balance Decrease:', initialBalance - updatedBalance.tokenCredits);
|
||||
});
|
||||
|
||||
test('spendTokens should handle zero completion tokens', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000; // $10.00
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
};
|
||||
|
||||
const tokenUsage = {
|
||||
promptTokens: 100,
|
||||
completionTokens: 0,
|
||||
};
|
||||
|
||||
// Act
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
await spendTokens(txData, tokenUsage);
|
||||
|
||||
// Assert
|
||||
const updatedBalance = await Balance.findOne({ user: userId });
|
||||
|
||||
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
||||
const expectedCost = tokenUsage.promptTokens * promptMultiplier;
|
||||
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
|
||||
console.log('Initial Balance:', initialBalance);
|
||||
console.log('Updated Balance:', updatedBalance.tokenCredits);
|
||||
console.log('Expected Cost:', expectedCost);
|
||||
});
|
||||
|
||||
test('spendTokens should handle undefined token counts', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000; // $10.00
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
};
|
||||
|
||||
const tokenUsage = {};
|
||||
|
||||
const result = await spendTokens(txData, tokenUsage);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('spendTokens should handle only prompt tokens', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000; // $10.00
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
};
|
||||
|
||||
const tokenUsage = { promptTokens: 100 };
|
||||
|
||||
await spendTokens(txData, tokenUsage);
|
||||
|
||||
const updatedBalance = await Balance.findOne({ user: userId });
|
||||
|
||||
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
||||
const expectedCost = 100 * promptMultiplier;
|
||||
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Structured Token Spending Tests', () => {
|
||||
test('Balance should decrease and rawAmount should be set when spending a large number of structured tokens', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 17613154.55; // $17.61
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'c23a18da-706c-470a-ac28-ec87ed065199',
|
||||
model,
|
||||
context: 'message',
|
||||
endpointTokenConfig: null, // We'll use the default rates
|
||||
};
|
||||
|
||||
const tokenUsage = {
|
||||
promptTokens: {
|
||||
input: 11,
|
||||
write: 140522,
|
||||
read: 0,
|
||||
},
|
||||
completionTokens: 5,
|
||||
};
|
||||
|
||||
// Get the actual multipliers
|
||||
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
||||
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
|
||||
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
|
||||
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
|
||||
|
||||
console.log('Multipliers:', {
|
||||
promptMultiplier,
|
||||
completionMultiplier,
|
||||
writeMultiplier,
|
||||
readMultiplier,
|
||||
});
|
||||
|
||||
// Act
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const result = await spendStructuredTokens(txData, tokenUsage);
|
||||
|
||||
// Assert
|
||||
console.log('Initial Balance:', initialBalance);
|
||||
console.log('Updated Balance:', result.completion.balance);
|
||||
console.log('Transaction Result:', result);
|
||||
|
||||
const expectedPromptCost =
|
||||
tokenUsage.promptTokens.input * promptMultiplier +
|
||||
tokenUsage.promptTokens.write * writeMultiplier +
|
||||
tokenUsage.promptTokens.read * readMultiplier;
|
||||
const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier;
|
||||
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
|
||||
const expectedBalance = initialBalance - expectedTotalCost;
|
||||
|
||||
console.log('Expected Cost:', expectedTotalCost);
|
||||
console.log('Expected Balance:', expectedBalance);
|
||||
|
||||
expect(result.completion.balance).toBeLessThan(initialBalance);
|
||||
|
||||
// Allow for a small difference (e.g., 100 token credits, which is $0.0001)
|
||||
const allowedDifference = 100;
|
||||
expect(Math.abs(result.completion.balance - expectedBalance)).toBeLessThan(allowedDifference);
|
||||
|
||||
// Check if the decrease is approximately as expected
|
||||
const balanceDecrease = initialBalance - result.completion.balance;
|
||||
expect(balanceDecrease).toBeCloseTo(expectedTotalCost, 0);
|
||||
|
||||
// Check token values
|
||||
const expectedPromptTokenValue = -(
|
||||
tokenUsage.promptTokens.input * promptMultiplier +
|
||||
tokenUsage.promptTokens.write * writeMultiplier +
|
||||
tokenUsage.promptTokens.read * readMultiplier
|
||||
);
|
||||
const expectedCompletionTokenValue = -tokenUsage.completionTokens * completionMultiplier;
|
||||
|
||||
expect(result.prompt.prompt).toBeCloseTo(expectedPromptTokenValue, 1);
|
||||
expect(result.completion.completion).toBe(expectedCompletionTokenValue);
|
||||
|
||||
console.log('Expected prompt tokenValue:', expectedPromptTokenValue);
|
||||
console.log('Actual prompt tokenValue:', result.prompt.prompt);
|
||||
console.log('Expected completion tokenValue:', expectedCompletionTokenValue);
|
||||
console.log('Actual completion tokenValue:', result.completion.completion);
|
||||
});
|
||||
|
||||
test('should handle zero completion tokens in structured spending', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 17613154.55;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-convo',
|
||||
model,
|
||||
context: 'message',
|
||||
};
|
||||
|
||||
const tokenUsage = {
|
||||
promptTokens: {
|
||||
input: 10,
|
||||
write: 100,
|
||||
read: 5,
|
||||
},
|
||||
completionTokens: 0,
|
||||
};
|
||||
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const result = await spendStructuredTokens(txData, tokenUsage);
|
||||
|
||||
expect(result.prompt).toBeDefined();
|
||||
expect(result.completion).toBeUndefined();
|
||||
expect(result.prompt.prompt).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('should handle only prompt tokens in structured spending', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 17613154.55;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-convo',
|
||||
model,
|
||||
context: 'message',
|
||||
};
|
||||
|
||||
const tokenUsage = {
|
||||
promptTokens: {
|
||||
input: 10,
|
||||
write: 100,
|
||||
read: 5,
|
||||
},
|
||||
};
|
||||
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const result = await spendStructuredTokens(txData, tokenUsage);
|
||||
|
||||
expect(result.prompt).toBeDefined();
|
||||
expect(result.completion).toBeUndefined();
|
||||
expect(result.prompt.prompt).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('should handle undefined token counts in structured spending', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 17613154.55;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-convo',
|
||||
model,
|
||||
context: 'message',
|
||||
};
|
||||
|
||||
const tokenUsage = {};
|
||||
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const result = await spendStructuredTokens(txData, tokenUsage);
|
||||
|
||||
expect(result).toEqual({
|
||||
prompt: undefined,
|
||||
completion: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle incomplete context for completion tokens', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 17613154.55;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-convo',
|
||||
model,
|
||||
context: 'incomplete',
|
||||
};
|
||||
|
||||
const tokenUsage = {
|
||||
promptTokens: {
|
||||
input: 10,
|
||||
write: 100,
|
||||
read: 5,
|
||||
},
|
||||
completionTokens: 50,
|
||||
};
|
||||
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const result = await spendStructuredTokens(txData, tokenUsage);
|
||||
|
||||
expect(result.completion.completion).toBeCloseTo(-50 * 15 * 1.15, 0); // Assuming multiplier is 15 and cancelRate is 1.15
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,3 @@
|
||||
const {
|
||||
getMessages,
|
||||
saveMessage,
|
||||
recordMessage,
|
||||
updateMessage,
|
||||
deleteMessagesSince,
|
||||
deleteMessages,
|
||||
} = require('./Message');
|
||||
const {
|
||||
comparePassword,
|
||||
deleteUserById,
|
||||
@@ -16,8 +8,6 @@ const {
|
||||
countUsers,
|
||||
findUser,
|
||||
} = require('./userMethods');
|
||||
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
|
||||
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
|
||||
const {
|
||||
findFileById,
|
||||
createFile,
|
||||
@@ -27,26 +17,40 @@ const {
|
||||
getFiles,
|
||||
updateFileUsage,
|
||||
} = require('./File');
|
||||
const Key = require('./Key');
|
||||
const User = require('./User');
|
||||
const {
|
||||
getMessages,
|
||||
saveMessage,
|
||||
recordMessage,
|
||||
updateMessage,
|
||||
deleteMessagesSince,
|
||||
deleteMessages,
|
||||
} = require('./Message');
|
||||
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
|
||||
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
|
||||
const { createToken, findToken, updateToken, deleteTokens } = require('./Token');
|
||||
const Session = require('./Session');
|
||||
const Balance = require('./Balance');
|
||||
const User = require('./User');
|
||||
const Key = require('./Key');
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
Key,
|
||||
Session,
|
||||
Balance,
|
||||
|
||||
comparePassword,
|
||||
deleteUserById,
|
||||
generateToken,
|
||||
getUserById,
|
||||
countUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
createUser,
|
||||
countUsers,
|
||||
findUser,
|
||||
|
||||
findFileById,
|
||||
createFile,
|
||||
updateFile,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
getFiles,
|
||||
updateFileUsage,
|
||||
|
||||
getMessages,
|
||||
saveMessage,
|
||||
recordMessage,
|
||||
@@ -64,11 +68,13 @@ module.exports = {
|
||||
savePreset,
|
||||
deletePresets,
|
||||
|
||||
findFileById,
|
||||
createFile,
|
||||
updateFile,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
getFiles,
|
||||
updateFileUsage,
|
||||
createToken,
|
||||
findToken,
|
||||
updateToken,
|
||||
deleteTokens,
|
||||
|
||||
User,
|
||||
Key,
|
||||
Session,
|
||||
Balance,
|
||||
};
|
||||
|
||||
70
api/models/inviteUser.js
Normal file
70
api/models/inviteUser.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const mongoose = require('mongoose');
|
||||
const { createToken, findToken } = require('./Token');
|
||||
const logger = require('~/config/winston');
|
||||
|
||||
/**
|
||||
* @module inviteUser
|
||||
* @description This module provides functions to create and get user invites
|
||||
*/
|
||||
|
||||
/**
|
||||
* @function createInvite
|
||||
* @description This function creates a new user invite
|
||||
* @param {string} email - The email of the user to invite
|
||||
* @returns {Promise<Object>} A promise that resolves to the saved invite document
|
||||
* @throws {Error} If there is an error creating the invite
|
||||
*/
|
||||
const createInvite = async (email) => {
|
||||
try {
|
||||
let token = crypto.randomBytes(32).toString('hex');
|
||||
const hash = bcrypt.hashSync(token, 10);
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
|
||||
const fakeUserId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createToken({
|
||||
userId: fakeUserId,
|
||||
email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
expiresIn: 604800,
|
||||
});
|
||||
|
||||
return encodedToken;
|
||||
} catch (error) {
|
||||
logger.error('[createInvite] Error creating invite', error);
|
||||
return { message: 'Error creating invite' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @function getInvite
|
||||
* @description This function retrieves a user invite
|
||||
* @param {string} encodedToken - The token of the invite to retrieve
|
||||
* @param {string} email - The email of the user to validate
|
||||
* @returns {Promise<Object>} A promise that resolves to the retrieved invite document
|
||||
* @throws {Error} If there is an error retrieving the invite, if the invite does not exist, or if the email does not match
|
||||
*/
|
||||
const getInvite = async (encodedToken, email) => {
|
||||
try {
|
||||
const token = decodeURIComponent(encodedToken);
|
||||
const hash = bcrypt.hashSync(token, 10);
|
||||
const invite = await findToken({ token: hash, email });
|
||||
|
||||
if (!invite) {
|
||||
throw new Error('Invite not found or email does not match');
|
||||
}
|
||||
|
||||
return invite;
|
||||
} catch (error) {
|
||||
logger.error('[getInvite] Error getting invite', error);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createInvite,
|
||||
getInvite,
|
||||
};
|
||||
@@ -74,6 +74,10 @@ const conversationPreset = {
|
||||
resendImages: {
|
||||
type: Boolean,
|
||||
},
|
||||
/* Anthropic only */
|
||||
promptCache: {
|
||||
type: Boolean,
|
||||
},
|
||||
// files
|
||||
resendFiles: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -8,6 +8,12 @@ const roleSchema = new mongoose.Schema({
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: {
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -18,8 +18,13 @@ const tokenSchema = new Schema({
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now,
|
||||
expires: 900,
|
||||
},
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Token', tokenSchema);
|
||||
tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
|
||||
module.exports = tokenSchema;
|
||||
|
||||
@@ -30,6 +30,9 @@ const transactionSchema = mongoose.Schema(
|
||||
rate: Number,
|
||||
rawAmount: Number,
|
||||
tokenValue: Number,
|
||||
inputTokens: { type: Number },
|
||||
writeTokens: { type: Number },
|
||||
readTokens: { type: Number },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -11,7 +11,7 @@ const { logger } = require('~/config');
|
||||
* @param {String} txData.conversationId - The ID of the conversation.
|
||||
* @param {String} txData.model - The model name.
|
||||
* @param {String} txData.context - The context in which the transaction is made.
|
||||
* @param {String} [txData.endpointTokenConfig] - The current endpoint token config.
|
||||
* @param {EndpointTokenConfig} [txData.endpointTokenConfig] - The current endpoint token config.
|
||||
* @param {String} [txData.valueKey] - The value key (optional).
|
||||
* @param {Object} tokenUsage - The number of tokens used.
|
||||
* @param {Number} tokenUsage.promptTokens - The number of prompt tokens used.
|
||||
@@ -32,38 +32,109 @@ const spendTokens = async (txData, tokenUsage) => {
|
||||
);
|
||||
let prompt, completion;
|
||||
try {
|
||||
if (promptTokens >= 0) {
|
||||
if (promptTokens !== undefined) {
|
||||
prompt = await Transaction.create({
|
||||
...txData,
|
||||
tokenType: 'prompt',
|
||||
rawAmount: -promptTokens,
|
||||
rawAmount: -Math.max(promptTokens, 0),
|
||||
});
|
||||
}
|
||||
|
||||
if (!completionTokens && isNaN(completionTokens)) {
|
||||
logger.debug('[spendTokens] !completionTokens', { prompt, completion });
|
||||
return;
|
||||
if (completionTokens !== undefined) {
|
||||
completion = await Transaction.create({
|
||||
...txData,
|
||||
tokenType: 'completion',
|
||||
rawAmount: -Math.max(completionTokens, 0),
|
||||
});
|
||||
}
|
||||
|
||||
completion = await Transaction.create({
|
||||
...txData,
|
||||
tokenType: 'completion',
|
||||
rawAmount: -completionTokens,
|
||||
});
|
||||
|
||||
prompt &&
|
||||
completion &&
|
||||
if (prompt || completion) {
|
||||
logger.debug('[spendTokens] Transaction data record against balance:', {
|
||||
user: txData.user,
|
||||
prompt: prompt.prompt,
|
||||
promptRate: prompt.rate,
|
||||
completion: completion.completion,
|
||||
completionRate: completion.rate,
|
||||
balance: completion.balance,
|
||||
prompt: prompt?.prompt,
|
||||
promptRate: prompt?.rate,
|
||||
completion: completion?.completion,
|
||||
completionRate: completion?.rate,
|
||||
balance: completion?.balance ?? prompt?.balance,
|
||||
});
|
||||
} else {
|
||||
logger.debug('[spendTokens] No transactions incurred against balance');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[spendTokens]', err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = spendTokens;
|
||||
/**
|
||||
* Creates transactions to record the spending of structured tokens.
|
||||
*
|
||||
* @function
|
||||
* @async
|
||||
* @param {Object} txData - Transaction data.
|
||||
* @param {mongoose.Schema.Types.ObjectId} txData.user - The user ID.
|
||||
* @param {String} txData.conversationId - The ID of the conversation.
|
||||
* @param {String} txData.model - The model name.
|
||||
* @param {String} txData.context - The context in which the transaction is made.
|
||||
* @param {EndpointTokenConfig} [txData.endpointTokenConfig] - The current endpoint token config.
|
||||
* @param {String} [txData.valueKey] - The value key (optional).
|
||||
* @param {Object} tokenUsage - The number of tokens used.
|
||||
* @param {Object} tokenUsage.promptTokens - The number of prompt tokens used.
|
||||
* @param {Number} tokenUsage.promptTokens.input - The number of input tokens.
|
||||
* @param {Number} tokenUsage.promptTokens.write - The number of write tokens.
|
||||
* @param {Number} tokenUsage.promptTokens.read - The number of read tokens.
|
||||
* @param {Number} tokenUsage.completionTokens - The number of completion tokens used.
|
||||
* @returns {Promise<void>} - Returns nothing.
|
||||
* @throws {Error} - Throws an error if there's an issue creating the transactions.
|
||||
*/
|
||||
const spendStructuredTokens = async (txData, tokenUsage) => {
|
||||
const { promptTokens, completionTokens } = tokenUsage;
|
||||
logger.debug(
|
||||
`[spendStructuredTokens] conversationId: ${txData.conversationId}${
|
||||
txData?.context ? ` | Context: ${txData?.context}` : ''
|
||||
} | Token usage: `,
|
||||
{
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
},
|
||||
);
|
||||
let prompt, completion;
|
||||
try {
|
||||
if (promptTokens) {
|
||||
const { input = 0, write = 0, read = 0 } = promptTokens;
|
||||
prompt = await Transaction.createStructured({
|
||||
...txData,
|
||||
tokenType: 'prompt',
|
||||
inputTokens: -input,
|
||||
writeTokens: -write,
|
||||
readTokens: -read,
|
||||
});
|
||||
}
|
||||
|
||||
if (completionTokens) {
|
||||
completion = await Transaction.create({
|
||||
...txData,
|
||||
tokenType: 'completion',
|
||||
rawAmount: -completionTokens,
|
||||
});
|
||||
}
|
||||
|
||||
if (prompt || completion) {
|
||||
logger.debug('[spendStructuredTokens] Transaction data record against balance:', {
|
||||
user: txData.user,
|
||||
prompt: prompt?.prompt,
|
||||
promptRate: prompt?.rate,
|
||||
completion: completion?.completion,
|
||||
completionRate: completion?.rate,
|
||||
balance: completion?.balance ?? prompt?.balance,
|
||||
});
|
||||
} else {
|
||||
logger.debug('[spendStructuredTokens] No transactions incurred against balance');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[spendStructuredTokens]', err);
|
||||
}
|
||||
|
||||
return { prompt, completion };
|
||||
};
|
||||
|
||||
module.exports = { spendTokens, spendStructuredTokens };
|
||||
|
||||
197
api/models/spendTokens.spec.js
Normal file
197
api/models/spendTokens.spec.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
jest.mock('./Transaction', () => ({
|
||||
Transaction: {
|
||||
create: jest.fn(),
|
||||
createStructured: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./Balance', () => ({
|
||||
findOne: jest.fn(),
|
||||
findOneAndUpdate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
||||
const { Transaction } = require('./Transaction');
|
||||
const Balance = require('./Balance');
|
||||
describe('spendTokens', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
});
|
||||
|
||||
it('should create transactions for both prompt and completion tokens', async () => {
|
||||
const txData = {
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
conversationId: 'test-convo',
|
||||
model: 'gpt-3.5-turbo',
|
||||
context: 'test',
|
||||
};
|
||||
const tokenUsage = {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
};
|
||||
|
||||
Transaction.create.mockResolvedValueOnce({ tokenType: 'prompt', rawAmount: -100 });
|
||||
Transaction.create.mockResolvedValueOnce({ tokenType: 'completion', rawAmount: -50 });
|
||||
Balance.findOne.mockResolvedValue({ tokenCredits: 10000 });
|
||||
Balance.findOneAndUpdate.mockResolvedValue({ tokenCredits: 9850 });
|
||||
|
||||
await spendTokens(txData, tokenUsage);
|
||||
|
||||
expect(Transaction.create).toHaveBeenCalledTimes(2);
|
||||
expect(Transaction.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokenType: 'prompt',
|
||||
rawAmount: -100,
|
||||
}),
|
||||
);
|
||||
expect(Transaction.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokenType: 'completion',
|
||||
rawAmount: -50,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle zero completion tokens', async () => {
|
||||
const txData = {
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
conversationId: 'test-convo',
|
||||
model: 'gpt-3.5-turbo',
|
||||
context: 'test',
|
||||
};
|
||||
const tokenUsage = {
|
||||
promptTokens: 100,
|
||||
completionTokens: 0,
|
||||
};
|
||||
|
||||
Transaction.create.mockResolvedValueOnce({ tokenType: 'prompt', rawAmount: -100 });
|
||||
Transaction.create.mockResolvedValueOnce({ tokenType: 'completion', rawAmount: -0 });
|
||||
Balance.findOne.mockResolvedValue({ tokenCredits: 10000 });
|
||||
Balance.findOneAndUpdate.mockResolvedValue({ tokenCredits: 9850 });
|
||||
|
||||
await spendTokens(txData, tokenUsage);
|
||||
|
||||
expect(Transaction.create).toHaveBeenCalledTimes(2);
|
||||
expect(Transaction.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokenType: 'prompt',
|
||||
rawAmount: -100,
|
||||
}),
|
||||
);
|
||||
expect(Transaction.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokenType: 'completion',
|
||||
rawAmount: -0, // Changed from 0 to -0
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined token counts', async () => {
|
||||
const txData = {
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
conversationId: 'test-convo',
|
||||
model: 'gpt-3.5-turbo',
|
||||
context: 'test',
|
||||
};
|
||||
const tokenUsage = {};
|
||||
|
||||
await spendTokens(txData, tokenUsage);
|
||||
|
||||
expect(Transaction.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update balance when CHECK_BALANCE is false', async () => {
|
||||
process.env.CHECK_BALANCE = 'false';
|
||||
const txData = {
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
conversationId: 'test-convo',
|
||||
model: 'gpt-3.5-turbo',
|
||||
context: 'test',
|
||||
};
|
||||
const tokenUsage = {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
};
|
||||
|
||||
Transaction.create.mockResolvedValueOnce({ tokenType: 'prompt', rawAmount: -100 });
|
||||
Transaction.create.mockResolvedValueOnce({ tokenType: 'completion', rawAmount: -50 });
|
||||
|
||||
await spendTokens(txData, tokenUsage);
|
||||
|
||||
expect(Transaction.create).toHaveBeenCalledTimes(2);
|
||||
expect(Balance.findOne).not.toHaveBeenCalled();
|
||||
expect(Balance.findOneAndUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create structured transactions for both prompt and completion tokens', async () => {
|
||||
const txData = {
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
conversationId: 'test-convo',
|
||||
model: 'claude-3-5-sonnet',
|
||||
context: 'test',
|
||||
};
|
||||
const tokenUsage = {
|
||||
promptTokens: {
|
||||
input: 10,
|
||||
write: 100,
|
||||
read: 5,
|
||||
},
|
||||
completionTokens: 50,
|
||||
};
|
||||
|
||||
Transaction.createStructured.mockResolvedValueOnce({
|
||||
rate: 3.75,
|
||||
user: txData.user.toString(),
|
||||
balance: 9570,
|
||||
prompt: -430,
|
||||
});
|
||||
Transaction.create.mockResolvedValueOnce({
|
||||
rate: 15,
|
||||
user: txData.user.toString(),
|
||||
balance: 8820,
|
||||
completion: -750,
|
||||
});
|
||||
|
||||
const result = await spendStructuredTokens(txData, tokenUsage);
|
||||
|
||||
expect(Transaction.createStructured).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokenType: 'prompt',
|
||||
inputTokens: -10,
|
||||
writeTokens: -100,
|
||||
readTokens: -5,
|
||||
}),
|
||||
);
|
||||
expect(Transaction.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokenType: 'completion',
|
||||
rawAmount: -50,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
prompt: expect.objectContaining({
|
||||
rate: 3.75,
|
||||
user: txData.user.toString(),
|
||||
balance: 9570,
|
||||
prompt: -430,
|
||||
}),
|
||||
completion: expect.objectContaining({
|
||||
rate: 15,
|
||||
user: txData.user.toString(),
|
||||
balance: 8820,
|
||||
completion: -750,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ const tokenValues = Object.assign(
|
||||
'claude-3-opus': { prompt: 15, completion: 75 },
|
||||
'claude-3-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-3-5-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-3.5-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
|
||||
'claude-2.1': { prompt: 8, completion: 24 },
|
||||
'claude-2': { prompt: 8, completion: 24 },
|
||||
@@ -70,6 +71,18 @@ const tokenValues = Object.assign(
|
||||
bedrockValues,
|
||||
);
|
||||
|
||||
/**
|
||||
* Mapping of model token sizes to their respective multipliers for cached input, read and write.
|
||||
* See Anthropic's documentation on this: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#pricing
|
||||
* The rates are 1 USD per 1M tokens.
|
||||
* @type {Object.<string, {write: number, read: number }>}
|
||||
*/
|
||||
const cacheTokenValues = {
|
||||
'claude-3.5-sonnet': { write: 3.75, read: 0.3 },
|
||||
'claude-3-5-sonnet': { write: 3.75, read: 0.3 },
|
||||
'claude-3-haiku': { write: 0.3, read: 0.03 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the key associated with a given model name.
|
||||
*
|
||||
@@ -122,7 +135,7 @@ const getValueKey = (model, endpoint) => {
|
||||
*
|
||||
* @param {Object} params - The parameters for the function.
|
||||
* @param {string} [params.valueKey] - The key corresponding to the model name.
|
||||
* @param {string} [params.tokenType] - The type of token (e.g., 'prompt' or 'completion').
|
||||
* @param {'prompt' | 'completion'} [params.tokenType] - The type of token (e.g., 'prompt' or 'completion').
|
||||
* @param {string} [params.model] - The model name to derive the value key from if not provided.
|
||||
* @param {string} [params.endpoint] - The endpoint name to derive the value key from if not provided.
|
||||
* @param {EndpointTokenConfig} [params.endpointTokenConfig] - The token configuration for the endpoint.
|
||||
@@ -147,7 +160,41 @@ const getMultiplier = ({ valueKey, tokenType, model, endpoint, endpointTokenConf
|
||||
}
|
||||
|
||||
// If we got this far, and values[tokenType] is undefined somehow, return a rough average of default multipliers
|
||||
return tokenValues[valueKey][tokenType] ?? defaultRate;
|
||||
return tokenValues[valueKey]?.[tokenType] ?? defaultRate;
|
||||
};
|
||||
|
||||
module.exports = { tokenValues, getValueKey, getMultiplier, defaultRate };
|
||||
/**
|
||||
* Retrieves the cache multiplier for a given value key and token type. If no value key is provided,
|
||||
* it attempts to derive it from the model name.
|
||||
*
|
||||
* @param {Object} params - The parameters for the function.
|
||||
* @param {string} [params.valueKey] - The key corresponding to the model name.
|
||||
* @param {'write' | 'read'} [params.cacheType] - The type of token (e.g., 'write' or 'read').
|
||||
* @param {string} [params.model] - The model name to derive the value key from if not provided.
|
||||
* @param {string} [params.endpoint] - The endpoint name to derive the value key from if not provided.
|
||||
* @param {EndpointTokenConfig} [params.endpointTokenConfig] - The token configuration for the endpoint.
|
||||
* @returns {number | null} The multiplier for the given parameters, or `null` if not found.
|
||||
*/
|
||||
const getCacheMultiplier = ({ valueKey, cacheType, model, endpoint, endpointTokenConfig }) => {
|
||||
if (endpointTokenConfig) {
|
||||
return endpointTokenConfig?.[model]?.[cacheType] ?? null;
|
||||
}
|
||||
|
||||
if (valueKey && cacheType) {
|
||||
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
|
||||
}
|
||||
|
||||
if (!cacheType || !model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
valueKey = getValueKey(model, endpoint);
|
||||
if (!valueKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we got this far, and values[cacheType] is undefined somehow, return a rough average of default multipliers
|
||||
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
|
||||
};
|
||||
|
||||
module.exports = { tokenValues, getValueKey, getMultiplier, getCacheMultiplier, defaultRate };
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
const { getValueKey, getMultiplier, defaultRate, tokenValues } = require('./tx');
|
||||
const {
|
||||
defaultRate,
|
||||
tokenValues,
|
||||
getValueKey,
|
||||
getMultiplier,
|
||||
getCacheMultiplier,
|
||||
} = require('./tx');
|
||||
|
||||
describe('getValueKey', () => {
|
||||
it('should return "16k" for model name containing "gpt-3.5-turbo-16k"', () => {
|
||||
@@ -63,12 +69,26 @@ describe('getValueKey', () => {
|
||||
expect(getValueKey('gpt-4o-2024-08-06-0718')).not.toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('should return "gpt-4o" for model type of "chatgpt-4o"', () => {
|
||||
expect(getValueKey('chatgpt-4o-latest')).toBe('gpt-4o');
|
||||
expect(getValueKey('openai/chatgpt-4o-latest')).toBe('gpt-4o');
|
||||
expect(getValueKey('chatgpt-4o-latest-0916')).toBe('gpt-4o');
|
||||
expect(getValueKey('chatgpt-4o-latest-0718')).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('should return "claude-3-5-sonnet" for model type of "claude-3-5-sonnet-"', () => {
|
||||
expect(getValueKey('claude-3-5-sonnet-20240620')).toBe('claude-3-5-sonnet');
|
||||
expect(getValueKey('anthropic/claude-3-5-sonnet')).toBe('claude-3-5-sonnet');
|
||||
expect(getValueKey('claude-3-5-sonnet-turbo')).toBe('claude-3-5-sonnet');
|
||||
expect(getValueKey('claude-3-5-sonnet-0125')).toBe('claude-3-5-sonnet');
|
||||
});
|
||||
|
||||
it('should return "claude-3.5-sonnet" for model type of "claude-3.5-sonnet-"', () => {
|
||||
expect(getValueKey('claude-3.5-sonnet-20240620')).toBe('claude-3.5-sonnet');
|
||||
expect(getValueKey('anthropic/claude-3.5-sonnet')).toBe('claude-3.5-sonnet');
|
||||
expect(getValueKey('claude-3.5-sonnet-turbo')).toBe('claude-3.5-sonnet');
|
||||
expect(getValueKey('claude-3.5-sonnet-0125')).toBe('claude-3.5-sonnet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMultiplier', () => {
|
||||
@@ -136,6 +156,17 @@ describe('getMultiplier', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for chatgpt-4o-latest', () => {
|
||||
const valueKey = getValueKey('chatgpt-4o-latest');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
||||
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-4o'].completion,
|
||||
);
|
||||
expect(getMultiplier({ valueKey, tokenType: 'completion' })).not.toBe(
|
||||
tokenValues['gpt-4o-mini'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should derive the valueKey from the model if not provided for new models', () => {
|
||||
expect(
|
||||
getMultiplier({ tokenType: 'prompt', model: 'gpt-3.5-turbo-1106-some-other-info' }),
|
||||
@@ -225,3 +256,76 @@ describe('AWS Bedrock Model Tests', () => {
|
||||
expect(results.every(Boolean)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCacheMultiplier', () => {
|
||||
it('should return the correct cache multiplier for a given valueKey and cacheType', () => {
|
||||
expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe(3.75);
|
||||
expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'read' })).toBe(0.3);
|
||||
expect(getCacheMultiplier({ valueKey: 'claude-3-haiku', cacheType: 'write' })).toBe(0.3);
|
||||
expect(getCacheMultiplier({ valueKey: 'claude-3-haiku', cacheType: 'read' })).toBe(0.03);
|
||||
});
|
||||
|
||||
it('should return null if cacheType is provided but not found in cacheTokenValues', () => {
|
||||
expect(
|
||||
getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'unknownType' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should derive the valueKey from the model if not provided', () => {
|
||||
expect(getCacheMultiplier({ cacheType: 'write', model: 'claude-3-5-sonnet-20240620' })).toBe(
|
||||
3.75,
|
||||
);
|
||||
expect(getCacheMultiplier({ cacheType: 'read', model: 'claude-3-haiku-20240307' })).toBe(0.03);
|
||||
});
|
||||
|
||||
it('should return null if only model or cacheType is missing', () => {
|
||||
expect(getCacheMultiplier({ cacheType: 'write' })).toBeNull();
|
||||
expect(getCacheMultiplier({ model: 'claude-3-5-sonnet' })).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if derived valueKey does not match any known patterns', () => {
|
||||
expect(getCacheMultiplier({ cacheType: 'write', model: 'gpt-4-some-other-info' })).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle endpointTokenConfig if provided', () => {
|
||||
const endpointTokenConfig = {
|
||||
'custom-model': {
|
||||
write: 5,
|
||||
read: 1,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
getCacheMultiplier({ model: 'custom-model', cacheType: 'write', endpointTokenConfig }),
|
||||
).toBe(5);
|
||||
expect(
|
||||
getCacheMultiplier({ model: 'custom-model', cacheType: 'read', endpointTokenConfig }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null if model is not found in endpointTokenConfig', () => {
|
||||
const endpointTokenConfig = {
|
||||
'custom-model': {
|
||||
write: 5,
|
||||
read: 1,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
getCacheMultiplier({ model: 'unknown-model', cacheType: 'write', endpointTokenConfig }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle models with "bedrock/" prefix', () => {
|
||||
expect(
|
||||
getCacheMultiplier({
|
||||
model: 'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0',
|
||||
cacheType: 'write',
|
||||
}),
|
||||
).toBe(3.75);
|
||||
expect(
|
||||
getCacheMultiplier({
|
||||
model: 'bedrock/anthropic.claude-3-haiku-20240307-v1:0',
|
||||
cacheType: 'read',
|
||||
}),
|
||||
).toBe(0.03);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "0.7.4",
|
||||
"version": "v0.7.5-rc1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -12,6 +12,7 @@
|
||||
"list-balances": "node ./list-balances.js",
|
||||
"user-stats": "node ./user-stats.js",
|
||||
"create-user": "node ./create-user.js",
|
||||
"invite-user": "node ./invite-user.js",
|
||||
"ban-user": "node ./ban-user.js",
|
||||
"delete-user": "node ./delete-user.js"
|
||||
},
|
||||
@@ -100,7 +101,8 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.5.0",
|
||||
"jest": "^29.7.0",
|
||||
"mongodb-memory-server": "^10.0.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const Balance = require('../../models/Balance');
|
||||
const Balance = require('~/models/Balance');
|
||||
|
||||
async function balanceController(req, res) {
|
||||
const { tokenCredits: balance = '' } =
|
||||
|
||||
@@ -16,9 +16,9 @@ const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const AppService = require('./services/AppService');
|
||||
const staticCache = require('./utils/staticCache');
|
||||
const noIndex = require('./middleware/noIndex');
|
||||
const routes = require('./routes');
|
||||
const staticCache = require('./utils/staticCache');
|
||||
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};
|
||||
|
||||
@@ -39,7 +39,7 @@ const startServer = async () => {
|
||||
|
||||
app.get('/health', (_req, res) => res.status(200).send('OK'));
|
||||
|
||||
// Middleware
|
||||
/* Middleware */
|
||||
app.use(noIndex);
|
||||
app.use(errorController);
|
||||
app.use(express.json({ limit: '3mb' }));
|
||||
@@ -48,10 +48,10 @@ const startServer = async () => {
|
||||
app.use(staticCache(app.locals.paths.dist));
|
||||
app.use(staticCache(app.locals.paths.fonts));
|
||||
app.use(staticCache(app.locals.paths.assets));
|
||||
app.set('trust proxy', 1); // trust first proxy
|
||||
app.set('trust proxy', 1); /* trust first proxy */
|
||||
app.use(cors());
|
||||
|
||||
if (DISABLE_COMPRESSION !== 'true') {
|
||||
if (!isEnabled(DISABLE_COMPRESSION)) {
|
||||
app.use(compression());
|
||||
}
|
||||
|
||||
@@ -61,12 +61,12 @@ const startServer = async () => {
|
||||
);
|
||||
}
|
||||
|
||||
// OAUTH
|
||||
/* OAUTH */
|
||||
app.use(passport.initialize());
|
||||
passport.use(await jwtLogin());
|
||||
passport.use(passportLogin());
|
||||
|
||||
// LDAP Auth
|
||||
/* LDAP Auth */
|
||||
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
|
||||
passport.use(ldapLogin);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ const startServer = async () => {
|
||||
}
|
||||
|
||||
app.use('/oauth', routes.oauth);
|
||||
// API Endpoints
|
||||
/* API Endpoints */
|
||||
app.use('/api/auth', routes.auth);
|
||||
app.use('/api/keys', routes.keys);
|
||||
app.use('/api/user', routes.user);
|
||||
|
||||
@@ -2,9 +2,9 @@ const { isAssistantsEndpoint } = require('librechat-data-provider');
|
||||
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
|
||||
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
||||
const clearPendingReq = require('~/cache/clearPendingReq');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const abortControllers = require('./abortControllers');
|
||||
const { saveMessage, getConvo } = require('~/models');
|
||||
const spendTokens = require('~/models/spendTokens');
|
||||
const { abortRun } = require('./abortRun');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { parseConvo, EModelEndpoint } = require('librechat-data-provider');
|
||||
const { parseCompactConvo, EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
|
||||
const assistants = require('~/server/services/Endpoints/assistants');
|
||||
@@ -24,7 +24,7 @@ const buildFunction = {
|
||||
|
||||
async function buildEndpointOption(req, res, next) {
|
||||
const { endpoint, endpointType } = req.body;
|
||||
const parsedBody = parseConvo({ endpoint, endpointType, conversation: req.body });
|
||||
const parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body });
|
||||
|
||||
if (req.app.locals.modelSpecs?.list && req.app.locals.modelSpecs?.enforce) {
|
||||
/** @type {{ list: TModelSpec[] }}*/
|
||||
|
||||
27
api/server/middleware/checkInviteUser.js
Normal file
27
api/server/middleware/checkInviteUser.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { getInvite } = require('~/models/inviteUser');
|
||||
const { deleteTokens } = require('~/models/Token');
|
||||
|
||||
async function checkInviteUser(req, res, next) {
|
||||
const token = req.body.token;
|
||||
|
||||
if (!token || token === 'undefined') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const invite = await getInvite(token, req.body.email);
|
||||
|
||||
if (!invite || invite.error === true) {
|
||||
return res.status(400).json({ message: 'Invalid invite token' });
|
||||
}
|
||||
|
||||
await deleteTokens({ token: invite.token });
|
||||
req.invite = invite;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(429).json({ message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = checkInviteUser;
|
||||
@@ -10,6 +10,7 @@ const requireLocalAuth = require('./requireLocalAuth');
|
||||
const canDeleteAccount = require('./canDeleteAccount');
|
||||
const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const validateModel = require('./validateModel');
|
||||
const moderateText = require('./moderateText');
|
||||
@@ -33,6 +34,7 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
checkInviteUser,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
canDeleteAccount,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
function validateRegistration(req, res, next) {
|
||||
if (req.invite) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (isEnabled(process.env.ALLOW_REGISTRATION)) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).send('Registration is not allowed.');
|
||||
return res.status(403).json({
|
||||
message: 'Registration is not allowed.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
checkBan,
|
||||
loginLimiter,
|
||||
requireJwtAuth,
|
||||
checkInviteUser,
|
||||
registerLimiter,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
@@ -32,7 +33,14 @@ router.post(
|
||||
loginController,
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController);
|
||||
router.post(
|
||||
'/register',
|
||||
registerLimiter,
|
||||
checkBan,
|
||||
checkInviteUser,
|
||||
validateRegistration,
|
||||
registrationController,
|
||||
);
|
||||
router.post(
|
||||
'/requestPasswordReset',
|
||||
resetPasswordLimiter,
|
||||
|
||||
@@ -8,7 +8,6 @@ const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const { forkConversation } = require('~/server/utils/import/fork');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { createImportLimiters } = require('~/server/middleware');
|
||||
const { updateTagsForConversation } = require('~/models/ConversationTag');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { sleep } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
@@ -174,18 +173,4 @@ router.post('/fork', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/tags/:conversationId', async (req, res) => {
|
||||
try {
|
||||
const conversationTags = await updateTagsForConversation(
|
||||
req.user.id,
|
||||
req.params.conversationId,
|
||||
req.body.tags,
|
||||
);
|
||||
res.status(200).json(conversationTags);
|
||||
} catch (error) {
|
||||
logger.error('Error updating conversation tags', error);
|
||||
res.status(500).send('Error updating conversation tags');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
const fs = require('fs').promises;
|
||||
const express = require('express');
|
||||
const { isUUID, checkOpenAIStorage } = require('librechat-data-provider');
|
||||
const {
|
||||
isUUID,
|
||||
checkOpenAIStorage,
|
||||
FileSources,
|
||||
EModelEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
filterFile,
|
||||
processFileUpload,
|
||||
processDeleteRequest,
|
||||
} = require('~/server/services/Files/process');
|
||||
const { initializeClient } = require('~/server/services/Endpoints/assistants');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
||||
const { getFiles } = require('~/models/File');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
@@ -113,7 +118,15 @@ router.get('/download/:userId/:file_id', async (req, res) => {
|
||||
|
||||
if (checkOpenAIStorage(file.source)) {
|
||||
req.body = { model: file.model };
|
||||
const { openai } = await initializeClient({ req, res });
|
||||
const endpointMap = {
|
||||
[FileSources.openai]: EModelEndpoint.assistants,
|
||||
[FileSources.azure]: EModelEndpoint.azureAssistants,
|
||||
};
|
||||
const { openai } = await getOpenAIClient({
|
||||
req,
|
||||
res,
|
||||
overrideEndpoint: endpointMap[file.source],
|
||||
});
|
||||
logger.debug(`Downloading file ${file_id} from OpenAI`);
|
||||
passThrough = await getDownloadStream(file_id, openai);
|
||||
setHeaders();
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
const express = require('express');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
getConversationTags,
|
||||
updateConversationTag,
|
||||
createConversationTag,
|
||||
deleteConversationTag,
|
||||
updateTagsForConversation,
|
||||
} = require('~/models/ConversationTag');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]);
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBookmarkAccess);
|
||||
|
||||
/**
|
||||
* GET /
|
||||
@@ -24,7 +32,7 @@ router.get('/', async (req, res) => {
|
||||
res.status(404).end();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting conversation tags:', error);
|
||||
logger.error('Error getting conversation tags:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
@@ -40,7 +48,7 @@ router.post('/', async (req, res) => {
|
||||
const tag = await createConversationTag(req.user.id, req.body);
|
||||
res.status(200).json(tag);
|
||||
} catch (error) {
|
||||
console.error('Error creating conversation tag:', error);
|
||||
logger.error('Error creating conversation tag:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
@@ -60,7 +68,7 @@ router.put('/:tag', async (req, res) => {
|
||||
res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating conversation tag:', error);
|
||||
logger.error('Error updating conversation tag:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
@@ -80,9 +88,29 @@ router.delete('/:tag', async (req, res) => {
|
||||
res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting conversation tag:', error);
|
||||
logger.error('Error deleting conversation tag:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /convo/:conversationId
|
||||
* Updates the tags for a conversation.
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.put('/convo/:conversationId', async (req, res) => {
|
||||
try {
|
||||
const conversationTags = await updateTagsForConversation(
|
||||
req.user.id,
|
||||
req.params.conversationId,
|
||||
req.body.tags,
|
||||
);
|
||||
res.status(200).json(conversationTags);
|
||||
} catch (error) {
|
||||
logger.error('Error updating conversation tags', error);
|
||||
res.status(500).send('Error updating conversation tags');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
87
api/server/services/AppService.interface.spec.js
Normal file
87
api/server/services/AppService.interface.spec.js
Normal file
@@ -0,0 +1,87 @@
|
||||
jest.mock('~/models/Role', () => ({
|
||||
initializeRoles: jest.fn(),
|
||||
updateAccessPermissions: jest.fn(),
|
||||
getRoleByName: jest.fn(),
|
||||
updateRoleByName: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./Config/loadCustomConfig', () => jest.fn());
|
||||
jest.mock('./start/interface', () => ({
|
||||
loadDefaultInterface: jest.fn(),
|
||||
}));
|
||||
jest.mock('./ToolService', () => ({
|
||||
loadAndFormatTools: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
jest.mock('./start/checks', () => ({
|
||||
checkVariables: jest.fn(),
|
||||
checkHealth: jest.fn(),
|
||||
checkConfig: jest.fn(),
|
||||
checkAzureVariables: jest.fn(),
|
||||
}));
|
||||
|
||||
const AppService = require('./AppService');
|
||||
const { loadDefaultInterface } = require('./start/interface');
|
||||
|
||||
describe('AppService interface configuration', () => {
|
||||
let app;
|
||||
let mockLoadCustomConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
app = { locals: {} };
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
mockLoadCustomConfig = require('./Config/loadCustomConfig');
|
||||
});
|
||||
|
||||
it('should set prompts and bookmarks to true when loadDefaultInterface returns true for both', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({});
|
||||
loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true });
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals.interfaceConfig.prompts).toBe(true);
|
||||
expect(app.locals.interfaceConfig.bookmarks).toBe(true);
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set prompts and bookmarks to false when loadDefaultInterface returns false for both', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } });
|
||||
loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false });
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals.interfaceConfig.prompts).toBe(false);
|
||||
expect(app.locals.interfaceConfig.bookmarks).toBe(false);
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set prompts and bookmarks when loadDefaultInterface returns undefined for both', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({});
|
||||
loadDefaultInterface.mockResolvedValue({});
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals.interfaceConfig.prompts).toBeUndefined();
|
||||
expect(app.locals.interfaceConfig.bookmarks).toBeUndefined();
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set prompts and bookmarks to different values when loadDefaultInterface returns different values', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } });
|
||||
loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false });
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals.interfaceConfig.prompts).toBe(true);
|
||||
expect(app.locals.interfaceConfig.bookmarks).toBe(false);
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ const AppService = async (app) => {
|
||||
|
||||
const socialLogins =
|
||||
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
|
||||
const interfaceConfig = loadDefaultInterface(config, configDefaults);
|
||||
const interfaceConfig = await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const defaultLocals = {
|
||||
paths,
|
||||
|
||||
@@ -23,6 +23,7 @@ jest.mock('./Files/Firebase/initialize', () => ({
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
initializeRoles: jest.fn(),
|
||||
updateAccessPermissions: jest.fn(),
|
||||
}));
|
||||
jest.mock('./ToolService', () => ({
|
||||
loadAndFormatTools: jest.fn().mockReturnValue({
|
||||
@@ -97,8 +98,6 @@ describe('AppService', () => {
|
||||
socialLogins: ['testLogin'],
|
||||
fileStrategy: 'testStrategy',
|
||||
interfaceConfig: expect.objectContaining({
|
||||
privacyPolicy: undefined,
|
||||
termsOfService: undefined,
|
||||
endpointsMenu: true,
|
||||
modelSelect: true,
|
||||
parameters: true,
|
||||
|
||||
@@ -10,12 +10,11 @@ const {
|
||||
generateToken,
|
||||
deleteUserById,
|
||||
} = require('~/models/userMethods');
|
||||
const { createToken, findToken, deleteTokens, Session } = require('~/models');
|
||||
const { sendEmail, checkEmailConfig } = require('~/server/utils');
|
||||
const { registerSchema } = require('~/strategies/validators');
|
||||
const { hashToken } = require('~/server/utils/crypto');
|
||||
const isDomainAllowed = require('./isDomainAllowed');
|
||||
const Token = require('~/models/schema/tokenSchema');
|
||||
const Session = require('~/models/Session');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const domains = {
|
||||
@@ -87,12 +86,13 @@ const sendVerificationEmail = async (user) => {
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await new Token({
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
}).save();
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
};
|
||||
@@ -103,7 +103,7 @@ const sendVerificationEmail = async (user) => {
|
||||
*/
|
||||
const verifyEmail = async (req) => {
|
||||
const { email, token } = req.body;
|
||||
let emailVerificationData = await Token.findOne({ email: decodeURIComponent(email) });
|
||||
let emailVerificationData = await findToken({ email: decodeURIComponent(email) });
|
||||
|
||||
if (!emailVerificationData) {
|
||||
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${email}]`);
|
||||
@@ -123,7 +123,7 @@ const verifyEmail = async (req) => {
|
||||
return new Error('User not found');
|
||||
}
|
||||
|
||||
await emailVerificationData.deleteOne();
|
||||
await deleteTokens({ token: emailVerificationData.token });
|
||||
logger.info(`[verifyEmail] Email verification successful. [Email: ${email}]`);
|
||||
return { message: 'Email verification was successful' };
|
||||
};
|
||||
@@ -231,18 +231,16 @@ const requestPasswordReset = async (req) => {
|
||||
};
|
||||
}
|
||||
|
||||
let token = await Token.findOne({ userId: user._id });
|
||||
if (token) {
|
||||
await token.deleteOne();
|
||||
}
|
||||
await deleteTokens({ userId: user._id });
|
||||
|
||||
const [resetToken, hash] = createTokenHash();
|
||||
|
||||
await new Token({
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
}).save();
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
|
||||
|
||||
@@ -282,7 +280,9 @@ const requestPasswordReset = async (req) => {
|
||||
* @returns
|
||||
*/
|
||||
const resetPassword = async (userId, token, password) => {
|
||||
let passwordResetToken = await Token.findOne({ userId });
|
||||
let passwordResetToken = await findToken({
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!passwordResetToken) {
|
||||
return new Error('Invalid or expired password reset token');
|
||||
@@ -310,7 +310,7 @@ const resetPassword = async (userId, token, password) => {
|
||||
});
|
||||
}
|
||||
|
||||
await passwordResetToken.deleteOne();
|
||||
await deleteTokens({ token: passwordResetToken.token });
|
||||
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
|
||||
return { message: 'Password reset was successful' };
|
||||
};
|
||||
@@ -366,7 +366,7 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
|
||||
const resendVerificationEmail = async (req) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
await Token.deleteMany({ email });
|
||||
await deleteTokens(email);
|
||||
const user = await findUser({ email }, 'email _id name');
|
||||
|
||||
if (!user) {
|
||||
@@ -392,12 +392,13 @@ const resendVerificationEmail = async (req) => {
|
||||
template: 'verifyEmail.handlebars',
|
||||
});
|
||||
|
||||
await new Token({
|
||||
await createToken({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: hash,
|
||||
createdAt: Date.now(),
|
||||
}).save();
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ const addTitle = async (req, { text, response, client }) => {
|
||||
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
|
||||
const key = `${req.user.id}-${response.conversationId}`;
|
||||
|
||||
const title = await client.titleConvo({ text, responseText: response?.text });
|
||||
const title = await client.titleConvo({
|
||||
text,
|
||||
responseText: response?.text,
|
||||
conversationId: response.conversationId,
|
||||
});
|
||||
await titleCache.set(key, title, 120000);
|
||||
await saveConvo(
|
||||
req,
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
|
||||
const buildOptions = (endpoint, parsedBody) => {
|
||||
const {
|
||||
modelLabel,
|
||||
promptPrefix,
|
||||
maxContextTokens,
|
||||
resendFiles,
|
||||
resendFiles = true,
|
||||
promptCache = true,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
...rest
|
||||
artifacts,
|
||||
...modelOptions
|
||||
} = parsedBody;
|
||||
const endpointOption = {
|
||||
|
||||
const endpointOption = removeNullishValues({
|
||||
endpoint,
|
||||
modelLabel,
|
||||
promptPrefix,
|
||||
resendFiles,
|
||||
promptCache,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
maxContextTokens,
|
||||
modelOptions: {
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
modelOptions,
|
||||
});
|
||||
|
||||
if (artifacts === 'default') {
|
||||
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
|
||||
}
|
||||
|
||||
return endpointOption;
|
||||
};
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
|
||||
const buildOptions = (endpoint, parsedBody) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, ...rest } = parsedBody;
|
||||
const endpointOption = {
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
|
||||
parsedBody;
|
||||
const endpointOption = removeNullishValues({
|
||||
endpoint,
|
||||
promptPrefix,
|
||||
assistant_id,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
modelOptions: {
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
modelOptions,
|
||||
});
|
||||
|
||||
if (artifacts === 'default') {
|
||||
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
|
||||
}
|
||||
|
||||
return endpointOption;
|
||||
};
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
|
||||
const buildOptions = (endpoint, parsedBody) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, ...rest } = parsedBody;
|
||||
const endpointOption = {
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
|
||||
parsedBody;
|
||||
const endpointOption = removeNullishValues({
|
||||
endpoint,
|
||||
promptPrefix,
|
||||
assistant_id,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
modelOptions: {
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
modelOptions,
|
||||
});
|
||||
|
||||
if (artifacts === 'default') {
|
||||
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
|
||||
}
|
||||
|
||||
return endpointOption;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
|
||||
const buildOptions = (endpoint, parsedBody, endpointType) => {
|
||||
const {
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
maxContextTokens,
|
||||
resendFiles,
|
||||
resendFiles = true,
|
||||
imageDetail,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
...rest
|
||||
artifacts,
|
||||
...modelOptions
|
||||
} = parsedBody;
|
||||
const endpointOption = {
|
||||
const endpointOption = removeNullishValues({
|
||||
endpoint,
|
||||
endpointType,
|
||||
chatGptLabel,
|
||||
@@ -21,10 +25,12 @@ const buildOptions = (endpoint, parsedBody, endpointType) => {
|
||||
greeting,
|
||||
spec,
|
||||
maxContextTokens,
|
||||
modelOptions: {
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
modelOptions,
|
||||
});
|
||||
|
||||
if (artifacts === 'default') {
|
||||
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
|
||||
}
|
||||
|
||||
return endpointOption;
|
||||
};
|
||||
|
||||
@@ -47,7 +47,11 @@ const addTitle = async (req, { text, response, client }) => {
|
||||
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
|
||||
const key = `${req.user.id}-${response.conversationId}`;
|
||||
|
||||
const title = await titleClient.titleConvo({ text, responseText: response?.text });
|
||||
const title = await titleClient.titleConvo({
|
||||
text,
|
||||
responseText: response?.text,
|
||||
conversationId: response.conversationId,
|
||||
});
|
||||
await titleCache.set(key, title, 120000);
|
||||
await saveConvo(
|
||||
req,
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
|
||||
const buildOptions = (endpoint, parsedBody) => {
|
||||
const { examples, modelLabel, promptPrefix, iconURL, greeting, spec, ...rest } = parsedBody;
|
||||
const endpointOption = {
|
||||
const {
|
||||
examples,
|
||||
endpoint,
|
||||
modelLabel,
|
||||
resendFiles = true,
|
||||
promptPrefix,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
modelOptions: {
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
artifacts,
|
||||
...modelOptions
|
||||
} = parsedBody;
|
||||
const endpointOption = removeNullishValues({
|
||||
examples,
|
||||
endpoint,
|
||||
modelLabel,
|
||||
resendFiles,
|
||||
promptPrefix,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
modelOptions,
|
||||
});
|
||||
|
||||
if (artifacts === 'default') {
|
||||
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
|
||||
}
|
||||
|
||||
return endpointOption;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
|
||||
const buildOptions = (endpoint, parsedBody) => {
|
||||
const {
|
||||
chatGptLabel,
|
||||
@@ -8,9 +11,10 @@ const buildOptions = (endpoint, parsedBody) => {
|
||||
greeting,
|
||||
spec,
|
||||
maxContextTokens,
|
||||
artifacts,
|
||||
...modelOptions
|
||||
} = parsedBody;
|
||||
const endpointOption = {
|
||||
const endpointOption = removeNullishValues({
|
||||
endpoint,
|
||||
tools:
|
||||
tools
|
||||
@@ -24,7 +28,11 @@ const buildOptions = (endpoint, parsedBody) => {
|
||||
spec,
|
||||
maxContextTokens,
|
||||
modelOptions,
|
||||
};
|
||||
});
|
||||
|
||||
if (artifacts === 'default') {
|
||||
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
|
||||
}
|
||||
|
||||
return endpointOption;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,11 @@ const addTitle = async (req, { text, response, client }) => {
|
||||
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
|
||||
const key = `${req.user.id}-${response.conversationId}`;
|
||||
|
||||
const title = await client.titleConvo({ text, responseText: response?.text });
|
||||
const title = await client.titleConvo({
|
||||
text,
|
||||
responseText: response?.text,
|
||||
conversationId: response.conversationId,
|
||||
});
|
||||
await titleCache.set(key, title, 120000);
|
||||
await saveConvo(
|
||||
req,
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
|
||||
const buildOptions = (endpoint, parsedBody) => {
|
||||
const {
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
maxContextTokens,
|
||||
resendFiles,
|
||||
resendFiles = true,
|
||||
imageDetail,
|
||||
iconURL,
|
||||
greeting,
|
||||
spec,
|
||||
...rest
|
||||
artifacts,
|
||||
...modelOptions
|
||||
} = parsedBody;
|
||||
const endpointOption = {
|
||||
|
||||
const endpointOption = removeNullishValues({
|
||||
endpoint,
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
@@ -20,10 +25,12 @@ const buildOptions = (endpoint, parsedBody) => {
|
||||
greeting,
|
||||
spec,
|
||||
maxContextTokens,
|
||||
modelOptions: {
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
modelOptions,
|
||||
});
|
||||
|
||||
if (artifacts === 'default') {
|
||||
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
|
||||
}
|
||||
|
||||
return endpointOption;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* This function retrieves the speechTab settings from the custom configuration
|
||||
@@ -15,6 +16,13 @@ const getCustomConfig = require('~/server/services/Config/getCustomConfig');
|
||||
async function getCustomConfigSpeech(req, res) {
|
||||
try {
|
||||
const customConfig = await getCustomConfig();
|
||||
|
||||
if (!customConfig) {
|
||||
return res.status(200).send({
|
||||
message: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const sttExternal = !!customConfig.speech?.stt;
|
||||
const ttsExternal = !!customConfig.speech?.tts;
|
||||
let settings = {
|
||||
@@ -22,7 +30,7 @@ async function getCustomConfigSpeech(req, res) {
|
||||
ttsExternal,
|
||||
};
|
||||
|
||||
if (!customConfig || !customConfig.speech?.speechTab) {
|
||||
if (!customConfig.speech?.speechTab) {
|
||||
return res.status(200).send(settings);
|
||||
}
|
||||
|
||||
@@ -50,7 +58,7 @@ async function getCustomConfigSpeech(req, res) {
|
||||
|
||||
return res.status(200).send(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to get custom config speech settings:', error);
|
||||
logger.error('Failed to get custom config speech settings:', error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const WebSocket = require('ws');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { CacheKeys, findLastSeparatorIndex, SEPARATORS } = require('librechat-data-provider');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -71,25 +71,6 @@ function assembleQuery(parameters) {
|
||||
return query;
|
||||
}
|
||||
|
||||
const SEPARATORS = ['.', '?', '!', '۔', '。', '‥', ';', '¡', '¿', '\n'];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {string[] | undefined} [separators]
|
||||
* @returns
|
||||
*/
|
||||
function findLastSeparatorIndex(text, separators = SEPARATORS) {
|
||||
let lastIndex = -1;
|
||||
for (const separator of separators) {
|
||||
const index = text.lastIndexOf(separator);
|
||||
if (index > lastIndex) {
|
||||
lastIndex = index;
|
||||
}
|
||||
}
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
const MAX_NOT_FOUND_COUNT = 6;
|
||||
const MAX_NO_CHANGE_COUNT = 10;
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ const {
|
||||
} = require('librechat-data-provider');
|
||||
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
|
||||
const { recordMessage, getMessages } = require('~/models/Message');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { saveConvo } = require('~/models/Conversation');
|
||||
const spendTokens = require('~/models/spendTokens');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { updateAccessPermissions } = require('~/models/Role');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Loads the default interface object.
|
||||
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
|
||||
* @param {TConfigDefaults} configDefaults - The custom configuration default values.
|
||||
* @returns {TCustomConfig['interface']} The default interface object.
|
||||
* @param {SystemRoles} [roleName] - The role to load the default interface for, defaults to `'USER'`.
|
||||
* @returns {Promise<TCustomConfig['interface']>} The default interface object.
|
||||
*/
|
||||
function loadDefaultInterface(config, configDefaults) {
|
||||
async function loadDefaultInterface(config, configDefaults, roleName = SystemRoles.USER) {
|
||||
const { interface: interfaceConfig } = config ?? {};
|
||||
const { interface: defaults } = configDefaults;
|
||||
const hasModelSpecs = config?.modelSpecs?.list?.length > 0;
|
||||
|
||||
const loadedInterface = {
|
||||
/** @type {TCustomConfig['interface']} */
|
||||
const loadedInterface = removeNullishValues({
|
||||
endpointsMenu:
|
||||
interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu),
|
||||
modelSelect: interfaceConfig?.modelSelect ?? (hasModelSpecs ? false : defaults.modelSelect),
|
||||
@@ -20,7 +29,14 @@ function loadDefaultInterface(config, configDefaults) {
|
||||
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
|
||||
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
|
||||
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
|
||||
};
|
||||
bookmarks: interfaceConfig?.bookmarks ?? defaults.bookmarks,
|
||||
prompts: interfaceConfig?.prompts ?? defaults.prompts,
|
||||
});
|
||||
|
||||
await updateAccessPermissions(roleName, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks },
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
const logSettings = () => {
|
||||
|
||||
81
api/server/services/start/interface.spec.js
Normal file
81
api/server/services/start/interface.spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const { SystemRoles, Permissions, PermissionTypes } = require('librechat-data-provider');
|
||||
const { updateAccessPermissions } = require('~/models/Role');
|
||||
const { loadDefaultInterface } = require('./interface');
|
||||
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('loadDefaultInterface', () => {
|
||||
it('should call updateAccessPermissions with the correct parameters when prompts and bookmarks are true', async () => {
|
||||
const config = { interface: { prompts: true, bookmarks: true } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with false when prompts and bookmarks are false', async () => {
|
||||
const config = { interface: { prompts: false, bookmarks: false } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with undefined when prompts and bookmarks are not specified in config', async () => {
|
||||
const config = {};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with undefined when prompts and bookmarks are explicitly undefined', async () => {
|
||||
const config = { interface: { prompts: undefined, bookmarks: undefined } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with mixed values for prompts and bookmarks', async () => {
|
||||
const config = { interface: { prompts: true, bookmarks: false } };
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with true when config is undefined', async () => {
|
||||
const config = undefined;
|
||||
const configDefaults = { interface: { prompts: true, bookmarks: true } };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
discordLogin,
|
||||
facebookLogin,
|
||||
} = require('~/strategies');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ const configureSocialLogins = (app) => {
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
};
|
||||
if (process.env.USE_REDIS) {
|
||||
if (isEnabled(process.env.USE_REDIS)) {
|
||||
const client = new Redis(process.env.REDIS_URI);
|
||||
client
|
||||
.on('error', (err) => logger.error('ioredis error:', err))
|
||||
|
||||
287
api/server/utils/emails/inviteUser.handlebars
Normal file
287
api/server/utils/emails/inviteUser.handlebars
Normal file
@@ -0,0 +1,287 @@
|
||||
<html
|
||||
xmlns='http://www.w3.org/1999/xhtml'
|
||||
xmlns:v='urn:schemas-microsoft-com:vml'
|
||||
xmlns:o='urn:schemas-microsoft-com:office:office'
|
||||
>
|
||||
|
||||
<head>
|
||||
<!--[if gte mso 9]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<meta name='x-apple-disable-message-reformatting' />
|
||||
<meta name='color-scheme' content='light dark' />
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
||||
<!--<![endif]-->
|
||||
<title></title>
|
||||
<style type='text/css'>
|
||||
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
|
||||
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
|
||||
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
|
||||
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
|
||||
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
|
||||
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
|
||||
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
|
||||
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
|
||||
collapse; } p { margin: 0; } .ie-container table, .mso-container table { table-layout: fixed;
|
||||
} * { line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
|
||||
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
|
||||
text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class='clean-body u_body'
|
||||
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
|
||||
>
|
||||
<!--[if IE]><div class="ie-container"><![endif]-->
|
||||
<!--[if mso]><div class="mso-container"><![endif]-->
|
||||
<table
|
||||
id='u_body'
|
||||
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr style='vertical-align: top'>
|
||||
<td
|
||||
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
|
||||
>
|
||||
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
|
||||
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
|
||||
<div
|
||||
class='u-row'
|
||||
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
|
||||
>
|
||||
<div
|
||||
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
|
||||
>
|
||||
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
|
||||
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
|
||||
<div
|
||||
class='u-col u-col-100'
|
||||
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
|
||||
>
|
||||
<div
|
||||
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
|
||||
>
|
||||
<!--[if (!mso)&(!IE)]><!-->
|
||||
<div
|
||||
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
|
||||
>
|
||||
<!--<![endif]-->
|
||||
<table
|
||||
style='font-family:arial,helvetica,sans-serif;'
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
width='100%'
|
||||
border='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
|
||||
align='left'
|
||||
>
|
||||
<!--[if mso]><table width="100%"><tr><td><![endif]-->
|
||||
<h1
|
||||
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
|
||||
>
|
||||
<div>
|
||||
<div>You have been invited to join {{appName}}!</div>
|
||||
</div>
|
||||
</div>
|
||||
</h1>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
style='font-family:arial,helvetica,sans-serif;'
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
width='100%'
|
||||
border='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
|
||||
align='left'
|
||||
>
|
||||
<div
|
||||
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
|
||||
>
|
||||
<div>Hi,</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
style='font-family:arial,helvetica,sans-serif;'
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
width='100%'
|
||||
border='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
|
||||
align='left'
|
||||
>
|
||||
<div
|
||||
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
|
||||
>
|
||||
<p style='line-height: 140%;'>You have been invited to join {{appName}}. Click the
|
||||
button below to create your account and get started.</p>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
style='font-family:arial,helvetica,sans-serif;'
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
width='100%'
|
||||
border='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
|
||||
align='left'
|
||||
>
|
||||
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
|
||||
<div align='left'>
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{inviteLink}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
|
||||
<a
|
||||
href='{{inviteLink}}'
|
||||
target='_blank'
|
||||
class='v-button'
|
||||
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
|
||||
>
|
||||
<span
|
||||
style='display:block;padding:10px 20px;line-height:120%;'
|
||||
><span style='line-height: 16.8px;'>Create Account</span></span>
|
||||
</span></span>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
style='font-family:arial,helvetica,sans-serif;'
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
width='100%'
|
||||
border='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
|
||||
align='left'
|
||||
>
|
||||
<div
|
||||
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
Hurry up, the invite will expiry in 7 days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
style='font-family:arial,helvetica,sans-serif;'
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
width='100%'
|
||||
border='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
|
||||
align='left'
|
||||
>
|
||||
<div
|
||||
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
|
||||
>
|
||||
<div>Best regards,</div>
|
||||
<div>The {{appName}} Team</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
style='font-family:arial,helvetica,sans-serif;'
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
width='100%'
|
||||
border='0'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
|
||||
align='left'
|
||||
>
|
||||
<div
|
||||
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
|
||||
>
|
||||
<div>
|
||||
<div><sub>©
|
||||
{{year}}
|
||||
{{appName}}. All rights reserved.</sub></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if (!mso)&(!IE)]><!-->
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</div>
|
||||
</div>
|
||||
<!--[if (mso)|(IE)]></td><![endif]-->
|
||||
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso]></div><![endif]-->
|
||||
<!--[if IE]></div><![endif]-->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,14 +1,14 @@
|
||||
const express = require('express');
|
||||
|
||||
const oneWeekInSeconds = 24 * 60 * 60 * 7;
|
||||
const oneDayInSeconds = 24 * 60 * 60;
|
||||
|
||||
const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneWeekInSeconds;
|
||||
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneWeekInSeconds * 4;
|
||||
const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds;
|
||||
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
|
||||
|
||||
const staticCache = (staticPath) =>
|
||||
express.static(staticPath, {
|
||||
setHeaders: (res) => {
|
||||
if (process.env.NODE_ENV.toLowerCase() !== 'production') {
|
||||
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,24 @@
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AnthropicMessage
|
||||
* @typedef {import('@anthropic-ai/sdk').default.MessageParam} AnthropicMessage
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AnthropicMessageStartEvent
|
||||
* @typedef {import('@anthropic-ai/sdk').default.MessageStartEvent} AnthropicMessageStartEvent
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AnthropicMessageDeltaEvent
|
||||
* @typedef {import('@anthropic-ai/sdk').default.MessageDeltaEvent} AnthropicMessageDeltaEvent
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports GenerativeModel
|
||||
* @typedef {import('@google/generative-ai').GenerativeModel} GenerativeModel
|
||||
@@ -1311,6 +1329,33 @@
|
||||
* @method messageCompleted Handles the completion of a message processing.
|
||||
*/
|
||||
|
||||
/* TX Types */
|
||||
|
||||
/**
|
||||
* @typedef {object} txData - Transaction data.
|
||||
* @property {mongoose.Schema.Types.ObjectId} user - The user ID.
|
||||
* @property {String} conversationId - The ID of the conversation.
|
||||
* @property {String} model - The model name.
|
||||
* @property {String} context - The context in which the transaction is made.
|
||||
* @property {EndpointTokenConfig} [endpointTokenConfig] - The current endpoint token config.
|
||||
* @property {object} [cacheUsage] - Cache usage, if any.
|
||||
* @property {String} [valueKey] - The value key (optional).
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#pricing
|
||||
* @typedef {object} AnthropicStreamUsage - Stream usage for Anthropic
|
||||
* @property {number} [input_tokens] - The number of input tokens used.
|
||||
* @property {number} [cache_creation_input_tokens] - The number of cache creation input tokens used (write).
|
||||
* @property {number} [cache_read_input_tokens] - The number of cache input tokens used (read).
|
||||
* @property {number} [output_tokens] - The number of output tokens used.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AnthropicStreamUsage} StreamUsage - Stream usage for all providers (currently only Anthropic)
|
||||
*/
|
||||
|
||||
/* Native app/client methods */
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,6 +60,7 @@ const anthropicModels = {
|
||||
'claude-3-sonnet': 200000,
|
||||
'claude-3-opus': 200000,
|
||||
'claude-3-5-sonnet': 200000,
|
||||
'claude-3.5-sonnet': 200000,
|
||||
};
|
||||
|
||||
const aggregateModels = { ...openAIModels, ...googleModels, ...anthropicModels, ...cohereModels };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "0.7.4",
|
||||
"version": "v0.7.5-rc1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,7 +28,8 @@
|
||||
},
|
||||
"homepage": "https://librechat.ai",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.5",
|
||||
"@ariakit/react": "^0.4.8",
|
||||
"@codesandbox/sandpack-react": "^2.18.2",
|
||||
"@dicebear/collection": "^7.0.4",
|
||||
"@dicebear/core": "^7.0.4",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
@@ -37,7 +38,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.0.5",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
@@ -79,19 +80,22 @@
|
||||
"react-gtm-module": "^2.0.11",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^8.0.6",
|
||||
"react-resizable-panels": "^1.0.9",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-zoom-pan-pinch": "^3.6.1",
|
||||
"recoil": "^0.7.7",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-highlight": "^6.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"rehype-raw": "^6.1.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"rehype-mermaid": "^2.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-directive": "^3.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-supersub": "^1.0.0",
|
||||
"tailwind-merge": "^1.9.1",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
|
||||
1
client/public/assets/deepseek.svg
Normal file
1
client/public/assets/deepseek.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71.69 52.76"><defs><style>.cls-1{fill:#4d6bfe;}</style></defs><path id="path" class="cls-1" d="M523.77,276.34c-.76-.38-1.08.33-1.53.69a4,4,0,0,0-.41.41,5.07,5.07,0,0,1-4.1,1.87,8,8,0,0,0-6.46,2.53,5.82,5.82,0,0,0-3.72-4.62,6.39,6.39,0,0,1-2.85-1.94,7.76,7.76,0,0,1-.92-2.31c-.16-.48-.32-1-.87-1.05s-.83.41-1.07.82a11,11,0,0,0-1.26,5.5,11.9,11.9,0,0,0,5.49,10.14.75.75,0,0,1,.39,1c-.25.84-.54,1.65-.79,2.49-.17.53-.41.65-1,.42a16.63,16.63,0,0,1-5.18-3.52c-2.56-2.48-4.88-5.21-7.76-7.35-.68-.5-1.36-1-2.06-1.41-2.94-2.86.39-5.2,1.16-5.48s.28-1.29-2.33-1.28-5,.88-8,2a8.23,8.23,0,0,1-1.39.41,28.67,28.67,0,0,0-8.61-.3,18.57,18.57,0,0,0-13.44,7.83c-4,5.47-4.91,11.67-3.76,18.15a27.68,27.68,0,0,0,10,16.88,26.8,26.8,0,0,0,19.23,6.39c4.43-.25,9.36-.84,14.92-5.55a13.84,13.84,0,0,0,5.32,1.18,17.24,17.24,0,0,0,5.09-.38c2.2-.46,2.05-2.5,1.25-2.87-6.43-3-5-1.78-6.3-2.77,3.27-3.87,8.2-7.89,10.13-20.92a12.44,12.44,0,0,0,0-2.52c0-.51.1-.71.68-.76a12.55,12.55,0,0,0,4.62-1.42c4.17-2.28,5.85-6,6.25-10.51A1.57,1.57,0,0,0,523.77,276.34Zm-36.34,40.37c-6.24-4.9-9.27-6.52-10.52-6.45s-1,1.41-.7,2.28a8.49,8.49,0,0,0,1.11,2.21,1.14,1.14,0,0,1-.34,1.8c-2,1.24-5.5-.42-5.66-.5a26.08,26.08,0,0,1-9.87-9.88,30.15,30.15,0,0,1-3.87-13.39c-.06-1.15.28-1.56,1.42-1.77a14.31,14.31,0,0,1,4.57-.11,28.56,28.56,0,0,1,16.33,8.29,54.06,54.06,0,0,1,6.58,8.63,41.46,41.46,0,0,0,7.41,8.71,24.36,24.36,0,0,0,2.66,2C494.16,318.82,490.16,318.87,487.43,316.71Zm3-19.23a.92.92,0,0,1,.92-.92.83.83,0,0,1,.32.06.8.8,0,0,1,.34.22.9.9,0,0,1,.25.64.92.92,0,0,1-1.83,0Zm9.29,4.76a5.27,5.27,0,0,1-1.77.48,3.75,3.75,0,0,1-2.38-.76,3.57,3.57,0,0,1-1.65-2.26,5.16,5.16,0,0,1,0-1.76,2,2,0,0,0-.71-2.17,3.1,3.1,0,0,0-2.06-.59,1.63,1.63,0,0,1-.76-.24.75.75,0,0,1-.34-1.07,3.47,3.47,0,0,1,.57-.62,3.9,3.9,0,0,1,3.43,0,10,10,0,0,1,3,2.34,18.62,18.62,0,0,1,2,2.73,10.9,10.9,0,0,1,1.33,2.53C500.65,301.47,500.4,302,499.71,302.24Z" transform="translate(-452.83 -271.91)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/unify.webp
Normal file
BIN
client/public/assets/unify.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -8,6 +8,7 @@ import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-qu
|
||||
import { ScreenshotProvider, ThemeProvider, useApiErrorBoundary } from './hooks';
|
||||
import { ToastProvider } from './Providers';
|
||||
import Toast from './components/ui/Toast';
|
||||
import { LiveAnnouncer } from '~/a11y';
|
||||
import { router } from './routes';
|
||||
|
||||
const App = () => {
|
||||
@@ -26,18 +27,20 @@ const App = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot>
|
||||
<ThemeProvider>
|
||||
<RadixToast.Provider>
|
||||
<ToastProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
</ToastProvider>
|
||||
</RadixToast.Provider>
|
||||
</ThemeProvider>
|
||||
<LiveAnnouncer>
|
||||
<ThemeProvider>
|
||||
<RadixToast.Provider>
|
||||
<ToastProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
</ToastProvider>
|
||||
</RadixToast.Provider>
|
||||
</ThemeProvider>
|
||||
</LiveAnnouncer>
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
28
client/src/Providers/AnnouncerContext.tsx
Normal file
28
client/src/Providers/AnnouncerContext.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// AnnouncerContext.tsx
|
||||
import React from 'react';
|
||||
|
||||
export interface AnnounceOptions {
|
||||
message: string;
|
||||
id?: string;
|
||||
isStream?: boolean;
|
||||
isComplete?: boolean;
|
||||
}
|
||||
|
||||
interface AnnouncerContextType {
|
||||
announceAssertive: (options: AnnounceOptions) => void;
|
||||
announcePolite: (options: AnnounceOptions) => void;
|
||||
}
|
||||
|
||||
const defaultContext: AnnouncerContextType = {
|
||||
announceAssertive: () => console.warn('Announcement failed, LiveAnnouncer context is missing'),
|
||||
announcePolite: () => console.warn('Announcement failed, LiveAnnouncer context is missing'),
|
||||
};
|
||||
|
||||
const AnnouncerContext = React.createContext<AnnouncerContextType>(defaultContext);
|
||||
|
||||
export const useLiveAnnouncer = () => {
|
||||
const context = React.useContext(AnnouncerContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AnnouncerContext;
|
||||
@@ -11,3 +11,4 @@ export * from './BookmarkContext';
|
||||
export * from './DashboardContext';
|
||||
export * from './AssistantsContext';
|
||||
export * from './AssistantsMapContext';
|
||||
export * from './AnnouncerContext';
|
||||
|
||||
22
client/src/a11y/Announcer.tsx
Normal file
22
client/src/a11y/Announcer.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// client/src/a11y/Announcer.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface AnnouncerProps {
|
||||
statusMessage: string;
|
||||
responseMessage: string;
|
||||
}
|
||||
|
||||
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => {
|
||||
return (
|
||||
<div className="sr-only">
|
||||
<div aria-live="assertive" aria-atomic="true">
|
||||
{statusMessage}
|
||||
</div>
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{responseMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Announcer;
|
||||
192
client/src/a11y/LiveAnnouncer.tsx
Normal file
192
client/src/a11y/LiveAnnouncer.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
// client/src/a11y/LiveAnnouncer.tsx
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { findLastSeparatorIndex } from 'librechat-data-provider';
|
||||
import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
|
||||
import AnnouncerContext from '~/Providers/AnnouncerContext';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import Announcer from './Announcer';
|
||||
|
||||
interface LiveAnnouncerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AnnouncementItem {
|
||||
message: string;
|
||||
id: string;
|
||||
isAssertive: boolean;
|
||||
}
|
||||
|
||||
/** Chunk size for processing text */
|
||||
const CHUNK_SIZE = 200;
|
||||
/** Minimum delay between announcements */
|
||||
const MIN_ANNOUNCEMENT_DELAY = 1000;
|
||||
/** Delay before clearing the live region */
|
||||
const CLEAR_DELAY = 5000;
|
||||
/** Regex to remove *, `, and _ from message text */
|
||||
const replacementRegex = /[*`_]/g;
|
||||
|
||||
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [responseMessage, setResponseMessage] = useState('');
|
||||
|
||||
const counterRef = useRef(0);
|
||||
const isAnnouncingRef = useRef(false);
|
||||
const politeProcessedTextRef = useRef('');
|
||||
const queueRef = useRef<AnnouncementItem[]>([]);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastAnnouncementTimeRef = useRef(0);
|
||||
|
||||
const localize = useLocalize();
|
||||
|
||||
/** Generates a unique ID for announcement messages */
|
||||
const generateUniqueId = (prefix: string) => {
|
||||
counterRef.current += 1;
|
||||
return `${prefix}-${counterRef.current}`;
|
||||
};
|
||||
|
||||
/** Processes the text in chunks and returns a chunk of text */
|
||||
const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => {
|
||||
const remainingText = text.slice(processedTextRef.current.length);
|
||||
|
||||
if (remainingText.length < CHUNK_SIZE) {
|
||||
return ''; /* Not enough characters to process */
|
||||
}
|
||||
|
||||
let separatorIndex = -1;
|
||||
let startIndex = CHUNK_SIZE;
|
||||
|
||||
while (separatorIndex === -1 && startIndex <= remainingText.length) {
|
||||
separatorIndex = findLastSeparatorIndex(remainingText.slice(startIndex));
|
||||
if (separatorIndex !== -1) {
|
||||
separatorIndex += startIndex; /* Adjust the index to account for the starting position */
|
||||
} else {
|
||||
startIndex += CHUNK_SIZE; /* Move the starting position by another CHUNK_SIZE characters */
|
||||
}
|
||||
}
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return ''; /* No separator found, wait for more text */
|
||||
}
|
||||
|
||||
const chunkText = remainingText.slice(0, separatorIndex + 1);
|
||||
processedTextRef.current += chunkText;
|
||||
return chunkText.trim();
|
||||
};
|
||||
|
||||
/** Localized event announcements, i.e., "the AI is replying, finished, etc." */
|
||||
const events: Record<string, string | undefined> = useMemo(
|
||||
() => ({ start: localize('com_a11y_start'), end: localize('com_a11y_end') }),
|
||||
[localize],
|
||||
);
|
||||
|
||||
const announceMessage = useCallback(
|
||||
(message: string, isAssertive: boolean) => {
|
||||
const setMessage = isAssertive ? setStatusMessage : setResponseMessage;
|
||||
setMessage(message);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
lastAnnouncementTimeRef.current = Date.now();
|
||||
isAnnouncingRef.current = true;
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
isAnnouncingRef.current = false;
|
||||
setMessage(''); // Clear the message after a delay
|
||||
if (queueRef.current.length > 0) {
|
||||
const nextAnnouncement = queueRef.current.shift();
|
||||
if (nextAnnouncement) {
|
||||
const { message: _msg, isAssertive } = nextAnnouncement;
|
||||
const nextMessage = (events[_msg] ?? _msg).replace(replacementRegex, '');
|
||||
announceMessage(nextMessage, isAssertive);
|
||||
}
|
||||
}
|
||||
},
|
||||
isAssertive ? MIN_ANNOUNCEMENT_DELAY : CLEAR_DELAY,
|
||||
);
|
||||
},
|
||||
[events],
|
||||
);
|
||||
|
||||
const addToQueue = useCallback(
|
||||
(item: AnnouncementItem) => {
|
||||
if (item.isAssertive) {
|
||||
/* For assertive messages, clear the queue and announce immediately */
|
||||
queueRef.current = [];
|
||||
const { message: _msg, isAssertive } = item;
|
||||
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
|
||||
announceMessage(message, isAssertive);
|
||||
} else {
|
||||
queueRef.current.push(item);
|
||||
if (!isAnnouncingRef.current) {
|
||||
const nextAnnouncement = queueRef.current.shift();
|
||||
if (nextAnnouncement) {
|
||||
const { message: _msg, isAssertive } = nextAnnouncement;
|
||||
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
|
||||
announceMessage(message, isAssertive);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[events, announceMessage],
|
||||
);
|
||||
|
||||
/** Announces a polite message */
|
||||
const announcePolite = useCallback(
|
||||
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
|
||||
const announcementId = id ?? generateUniqueId('polite');
|
||||
if (isStream || isComplete) {
|
||||
const chunk = processChunks(message, politeProcessedTextRef);
|
||||
if (chunk) {
|
||||
addToQueue({ message: chunk, id: announcementId, isAssertive: false });
|
||||
}
|
||||
if (isComplete) {
|
||||
const remainingText = message.slice(politeProcessedTextRef.current.length);
|
||||
if (remainingText.trim()) {
|
||||
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
|
||||
}
|
||||
politeProcessedTextRef.current = '';
|
||||
}
|
||||
} else {
|
||||
addToQueue({ message, id: announcementId, isAssertive: false });
|
||||
politeProcessedTextRef.current = '';
|
||||
}
|
||||
},
|
||||
[addToQueue],
|
||||
);
|
||||
|
||||
/** Announces an assertive message */
|
||||
const announceAssertive = useCallback(
|
||||
({ message, id }: AnnounceOptions) => {
|
||||
const announcementId = id ?? generateUniqueId('assertive');
|
||||
addToQueue({ message, id: announcementId, isAssertive: true });
|
||||
},
|
||||
[addToQueue],
|
||||
);
|
||||
|
||||
const contextValue = {
|
||||
announcePolite,
|
||||
announceAssertive,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
queueRef.current = [];
|
||||
isAnnouncingRef.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnnouncerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<Announcer statusMessage={statusMessage} responseMessage={responseMessage} />
|
||||
</AnnouncerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveAnnouncer;
|
||||
37
client/src/a11y/LiveMessage.tsx
Normal file
37
client/src/a11y/LiveMessage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect, useContext } from 'react';
|
||||
import AnnouncerContext from '~/Providers/AnnouncerContext';
|
||||
|
||||
interface LiveMessageProps {
|
||||
message: string;
|
||||
'aria-live': 'polite' | 'assertive';
|
||||
clearOnUnmount?: boolean | 'true' | 'false';
|
||||
}
|
||||
|
||||
const LiveMessage: React.FC<LiveMessageProps> = ({
|
||||
message,
|
||||
'aria-live': ariaLive,
|
||||
clearOnUnmount,
|
||||
}) => {
|
||||
const { announceAssertive, announcePolite } = useContext(AnnouncerContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (ariaLive === 'assertive') {
|
||||
announceAssertive(message);
|
||||
} else if (ariaLive === 'polite') {
|
||||
announcePolite(message);
|
||||
}
|
||||
}, [message, ariaLive, announceAssertive, announcePolite]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearOnUnmount === true || clearOnUnmount === 'true') {
|
||||
announceAssertive('');
|
||||
announcePolite('');
|
||||
}
|
||||
};
|
||||
}, [clearOnUnmount, announceAssertive, announcePolite]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default LiveMessage;
|
||||
12
client/src/a11y/LiveMessenger.tsx
Normal file
12
client/src/a11y/LiveMessenger.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import AnnouncerContext from '~/Providers/AnnouncerContext';
|
||||
|
||||
interface LiveMessengerProps {
|
||||
children: (context: React.ContextType<typeof AnnouncerContext>) => React.ReactNode;
|
||||
}
|
||||
|
||||
const LiveMessenger: React.FC<LiveMessengerProps> = ({ children }) => (
|
||||
<AnnouncerContext.Consumer>{(contextProps) => children(contextProps)}</AnnouncerContext.Consumer>
|
||||
);
|
||||
|
||||
export default LiveMessenger;
|
||||
26
client/src/a11y/MessageBlock.tsx
Normal file
26
client/src/a11y/MessageBlock.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
const offScreenStyle: React.CSSProperties = {
|
||||
border: 0,
|
||||
clip: 'rect(0 0 0 0)',
|
||||
height: '1px',
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: 0,
|
||||
width: '1px',
|
||||
position: 'absolute',
|
||||
};
|
||||
|
||||
interface MessageBlockProps {
|
||||
message: string;
|
||||
'aria-live': 'polite' | 'assertive';
|
||||
}
|
||||
|
||||
const MessageBlock: React.FC<MessageBlockProps> = ({ message, 'aria-live': ariaLive }) => (
|
||||
<div style={offScreenStyle} role="log" aria-live={ariaLive}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MessageBlock;
|
||||
1
client/src/a11y/index.ts
Normal file
1
client/src/a11y/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LiveAnnouncer } from './LiveAnnouncer';
|
||||
15
client/src/common/artifacts.ts
Normal file
15
client/src/common/artifacts.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface CodeBlock {
|
||||
id: string;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
lastUpdateTime: number;
|
||||
identifier?: string;
|
||||
language?: string;
|
||||
content?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './artifacts';
|
||||
export * from './types';
|
||||
export * from './assistants-types';
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
TStartupConfig,
|
||||
EModelEndpoint,
|
||||
AssistantsEndpoint,
|
||||
TMessageContentParts,
|
||||
AuthorizationTypeEnum,
|
||||
TSetOption as SetOption,
|
||||
TokenExchangeMethodEnum,
|
||||
@@ -31,6 +32,17 @@ export enum PromptsEditorMode {
|
||||
ADVANCED = 'advanced',
|
||||
}
|
||||
|
||||
export enum STTEndpoints {
|
||||
browser = 'browser',
|
||||
external = 'external',
|
||||
}
|
||||
|
||||
export enum TTSEndpoints {
|
||||
browser = 'browser',
|
||||
edge = 'edge',
|
||||
external = 'external',
|
||||
}
|
||||
|
||||
export type AudioChunk = {
|
||||
audio: string;
|
||||
isFinal: boolean;
|
||||
@@ -100,10 +112,12 @@ export interface NavProps {
|
||||
defaultActive?: string;
|
||||
}
|
||||
|
||||
interface ColumnMeta {
|
||||
meta: {
|
||||
size: number | string;
|
||||
};
|
||||
export interface DataColumnMeta {
|
||||
meta:
|
||||
| {
|
||||
size: number | string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export enum Panel {
|
||||
@@ -145,7 +159,7 @@ export type AssistantPanelProps = {
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
};
|
||||
|
||||
export type AugmentedColumnDef<TData, TValue> = ColumnDef<TData, TValue> & ColumnMeta;
|
||||
export type AugmentedColumnDef<TData, TValue> = ColumnDef<TData, TValue> & DataColumnMeta;
|
||||
|
||||
export type TSetOption = SetOption;
|
||||
|
||||
@@ -374,6 +388,19 @@ export type Option = Record<string, unknown> & {
|
||||
value: string | number | null;
|
||||
};
|
||||
|
||||
export type VoiceOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TMessageAudio = {
|
||||
messageId?: string;
|
||||
content?: TMessageContentParts[] | string;
|
||||
className?: string;
|
||||
isLast: boolean;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type OptionWithIcon = Option & { icon?: React.ReactNode };
|
||||
export type MentionOption = OptionWithIcon & {
|
||||
type: string;
|
||||
@@ -434,17 +461,26 @@ export type NewConversationParams = {
|
||||
|
||||
export type ConvoGenerator = (params: NewConversationParams) => void | TConversation;
|
||||
|
||||
export type TResData = {
|
||||
export type TBaseResData = {
|
||||
plugin?: TResPlugin;
|
||||
final?: boolean;
|
||||
initial?: boolean;
|
||||
previousMessages?: TMessage[];
|
||||
requestMessage: TMessage;
|
||||
responseMessage: TMessage;
|
||||
conversation: TConversation;
|
||||
conversationId?: string;
|
||||
runMessages?: TMessage[];
|
||||
};
|
||||
|
||||
export type TResData = TBaseResData & {
|
||||
requestMessage: TMessage;
|
||||
responseMessage: TMessage;
|
||||
};
|
||||
|
||||
export type TFinalResData = TBaseResData & {
|
||||
requestMessage?: TMessage;
|
||||
responseMessage?: TMessage;
|
||||
};
|
||||
|
||||
export type TVectorStore = {
|
||||
_id: string;
|
||||
object: 'vector_store';
|
||||
|
||||
104
client/src/components/Artifacts/Artifact.tsx
Normal file
104
client/src/components/Artifacts/Artifact.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import type { Pluggable } from 'unified';
|
||||
import type { Artifact } from '~/common';
|
||||
import { artifactsState } from '~/store/artifacts';
|
||||
import ArtifactButton from './ArtifactButton';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
export const artifactPlugin: Pluggable = () => {
|
||||
return (tree) => {
|
||||
visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node) => {
|
||||
node.data = {
|
||||
hName: node.name,
|
||||
hProperties: node.attributes,
|
||||
...node.data,
|
||||
};
|
||||
return node;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const extractContent = (
|
||||
children: React.ReactNode | { props: { children: React.ReactNode } } | string,
|
||||
): string => {
|
||||
if (typeof children === 'string') {
|
||||
return children;
|
||||
}
|
||||
if (React.isValidElement(children)) {
|
||||
return extractContent((children.props as { children?: React.ReactNode }).children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(extractContent).join('');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export function Artifact({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
node,
|
||||
...props
|
||||
}: Artifact & {
|
||||
children: React.ReactNode | { props: { children: React.ReactNode } };
|
||||
node: unknown;
|
||||
}) {
|
||||
const setArtifacts = useSetRecoilState(artifactsState);
|
||||
const [artifact, setArtifact] = useState<Artifact | null>(null);
|
||||
|
||||
const throttledUpdateRef = useRef(
|
||||
throttle((updateFn: () => void) => {
|
||||
updateFn();
|
||||
}, 25),
|
||||
);
|
||||
|
||||
const updateArtifact = useCallback(() => {
|
||||
const content = extractContent(props.children);
|
||||
logger.log('artifacts', 'updateArtifact: content.length', content.length);
|
||||
|
||||
if (!content || content.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = props.title ?? 'Untitled Artifact';
|
||||
const type = props.type ?? 'unknown';
|
||||
const identifier = props.identifier ?? 'no-identifier';
|
||||
const artifactKey = `${identifier}_${type}_${title}`.replace(/\s+/g, '_').toLowerCase();
|
||||
|
||||
throttledUpdateRef.current(() => {
|
||||
const now = Date.now();
|
||||
|
||||
const currentArtifact: Artifact = {
|
||||
id: artifactKey,
|
||||
identifier,
|
||||
title,
|
||||
type,
|
||||
content,
|
||||
lastUpdateTime: now,
|
||||
};
|
||||
|
||||
setArtifacts((prevArtifacts) => {
|
||||
if (
|
||||
prevArtifacts?.[artifactKey] != null &&
|
||||
prevArtifacts[artifactKey].content === content
|
||||
) {
|
||||
return prevArtifacts;
|
||||
}
|
||||
|
||||
return {
|
||||
...prevArtifacts,
|
||||
[artifactKey]: currentArtifact,
|
||||
};
|
||||
});
|
||||
|
||||
setArtifact(currentArtifact);
|
||||
});
|
||||
}, [props.type, props.title, setArtifacts, props.children, props.identifier]);
|
||||
|
||||
useEffect(() => {
|
||||
updateArtifact();
|
||||
}, [updateArtifact]);
|
||||
|
||||
return <ArtifactButton artifact={artifact} />;
|
||||
}
|
||||
44
client/src/components/Artifacts/ArtifactButton.tsx
Normal file
44
client/src/components/Artifacts/ArtifactButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import type { Artifact } from '~/common';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { getFileType } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
const localize = useLocalize();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisible);
|
||||
const setArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
if (artifact === null || artifact === undefined) {
|
||||
return null;
|
||||
}
|
||||
const fileType = getFileType('artifact');
|
||||
|
||||
return (
|
||||
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setArtifactId(artifact.id);
|
||||
setVisible(true);
|
||||
}}
|
||||
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
|
||||
>
|
||||
<div className="w-60 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')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtifactButton;
|
||||
70
client/src/components/Artifacts/ArtifactPreview.tsx
Normal file
70
client/src/components/Artifacts/ArtifactPreview.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useMemo, memo } from 'react';
|
||||
import { Sandpack } from '@codesandbox/sandpack-react';
|
||||
import { removeNullishValues } from 'librechat-data-provider';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { Artifact } from '~/common';
|
||||
import {
|
||||
sharedFiles,
|
||||
sharedProps,
|
||||
getTemplate,
|
||||
sharedOptions,
|
||||
getArtifactFilename,
|
||||
} from '~/utils/artifacts';
|
||||
|
||||
export const ArtifactPreview = memo(function ({
|
||||
showEditor = false,
|
||||
artifact,
|
||||
previewRef,
|
||||
}: {
|
||||
showEditor?: boolean;
|
||||
artifact: Artifact;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
}) {
|
||||
const files = useMemo(() => {
|
||||
return removeNullishValues({ [getArtifactFilename(artifact.type ?? '')]: artifact.content });
|
||||
}, [artifact.type, artifact.content]);
|
||||
|
||||
const template = useMemo(
|
||||
() => getTemplate(artifact.type ?? '', artifact.language),
|
||||
[artifact.type, artifact.language],
|
||||
);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return showEditor ? (
|
||||
<Sandpack
|
||||
options={{
|
||||
showNavigator: true,
|
||||
editorHeight: '80vh',
|
||||
showTabs: true,
|
||||
...sharedOptions,
|
||||
}}
|
||||
files={{
|
||||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
/>
|
||||
) : (
|
||||
<SandpackProvider
|
||||
files={{
|
||||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
options={{ ...sharedOptions }}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
>
|
||||
<SandpackPreview
|
||||
showOpenInCodeSandbox={false}
|
||||
showRefreshButton={false}
|
||||
tabIndex={0}
|
||||
ref={previewRef}
|
||||
/>
|
||||
</SandpackProvider>
|
||||
);
|
||||
});
|
||||
201
client/src/components/Artifacts/Artifacts.tsx
Normal file
201
client/src/components/Artifacts/Artifacts.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { SandpackPreviewRef } from '@codesandbox/sandpack-react';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import { CodeMarkdown, CopyCodeButton } from './Code';
|
||||
import { getFileExtension } from '~/utils/artifacts';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Artifacts() {
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
isSubmitting,
|
||||
setActiveTab,
|
||||
currentIndex,
|
||||
cycleArtifact,
|
||||
currentArtifact,
|
||||
orderedArtifactIds,
|
||||
} = useArtifacts();
|
||||
|
||||
if (currentArtifact === null || currentArtifact === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
const client = previewRef.current?.getClient();
|
||||
if (client != null) {
|
||||
client.dispatch({ type: 'refresh' });
|
||||
}
|
||||
setTimeout(() => setIsRefreshing(false), 750);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
{/* Main Parent */}
|
||||
<div className="flex h-full w-full items-center justify-center py-2">
|
||||
{/* Main Container */}
|
||||
<div
|
||||
className={`flex h-[97%] w-[97%] flex-col overflow-hidden rounded-xl border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
|
||||
isVisible
|
||||
? 'translate-x-0 scale-100 opacity-100'
|
||||
: 'translate-x-full scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* 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={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Refresh button */}
|
||||
{activeTab === 'preview' && (
|
||||
<button
|
||||
className={`mr-2 text-text-secondary transition-transform duration-500 ease-in-out ${
|
||||
isRefreshing ? 'rotate-180' : ''
|
||||
}`}
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`transform ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
|
||||
<Tabs.Trigger
|
||||
value="preview"
|
||||
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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Code
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<button
|
||||
className="text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<Tabs.Content
|
||||
value="code"
|
||||
className={cn('flex-grow overflow-x-auto overflow-y-scroll bg-gray-900 p-4')}
|
||||
>
|
||||
<CodeMarkdown
|
||||
content={`\`\`\`${getFileExtension(currentArtifact.type)}\n${
|
||||
currentArtifact.content ?? ''
|
||||
}\`\`\``}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto bg-white">
|
||||
<ArtifactPreview
|
||||
artifact={currentArtifact}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
{/* 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">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-xs">{`${currentIndex + 1} / ${
|
||||
orderedArtifactIds.length
|
||||
}`}</span>
|
||||
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||
{/* Download Button */}
|
||||
{/* <button className="mr-2 text-text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z" />
|
||||
</svg>
|
||||
</button> */}
|
||||
{/* 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> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
119
client/src/components/Artifacts/Code.tsx
Normal file
119
client/src/components/Artifacts/Code.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { handleDoubleClick, langSubset } from '~/utils';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
||||
type TCodeProps = {
|
||||
inline: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const code: React.ElementType = memo(({ inline, className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
|
||||
});
|
||||
|
||||
export const CodeMarkdown = memo(
|
||||
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
const currentContent = content;
|
||||
const rehypePlugins = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
|
||||
if (!isNearBottom) {
|
||||
setUserScrolled(true);
|
||||
} else {
|
||||
setUserScrolled(false);
|
||||
}
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer || !isSubmitting || userScrolled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}, [content, isSubmitting, userScrolled]);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="max-h-full overflow-y-auto">
|
||||
<ReactMarkdown
|
||||
/* @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={
|
||||
{ code } as {
|
||||
[key: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
>
|
||||
{currentContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(content, { format: 'text/plain' });
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="mr-2 text-text-secondary"
|
||||
onClick={handleCopy}
|
||||
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
33
client/src/components/Artifacts/Mermaid.tsx
Normal file
33
client/src/components/Artifacts/Mermaid.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
interface MermaidProps {
|
||||
chart: string;
|
||||
}
|
||||
|
||||
const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
|
||||
const mermaidRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'default',
|
||||
securityLevel: 'strict',
|
||||
});
|
||||
mermaid.contentLoaded();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chart && mermaidRef.current) {
|
||||
mermaid.render('mermaid-svg', chart).then((svgCode) => {
|
||||
if (mermaidRef.current) {
|
||||
mermaidRef.current.innerHTML = svgCode.svg;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
return <div ref={mermaidRef} />;
|
||||
};
|
||||
|
||||
export default Mermaid;
|
||||
230
client/src/components/Artifacts/example.tsx
Normal file
230
client/src/components/Artifacts/example.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import dedent from 'dedent';
|
||||
import { Sandpack } from '@codesandbox/sandpack-react';
|
||||
import {
|
||||
SandpackPreview,
|
||||
SandpackProvider,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
// import './code-viewer.css';
|
||||
|
||||
const App = `import React, { useState } from 'react';
|
||||
import './styles.css';
|
||||
|
||||
function App() {
|
||||
const [result, setResult] = useState('');
|
||||
|
||||
const handleClick = (e) => {
|
||||
setResult(result.concat(e.target.name));
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setResult('');
|
||||
}
|
||||
|
||||
const backspace = () => {
|
||||
setResult(result.slice(0, -1));
|
||||
}
|
||||
|
||||
const calculate = () => {
|
||||
try {
|
||||
setResult(eval(result).toString());
|
||||
} catch(err) {
|
||||
setResult('Error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calculator">
|
||||
<input type="text" value={result} />
|
||||
<div className="keypad">
|
||||
<button className="highlight" onClick={clear} id="clear">Clear</button>
|
||||
<button className="highlight" onClick={backspace} id="backspace">C</button>
|
||||
<button className="highlight" name="/" onClick={handleClick}>÷</button>
|
||||
<button name="7" onClick={handleClick}>7</button>
|
||||
<button name="8" onClick={handleClick}>8</button>
|
||||
<button name="9" onClick={handleClick}>9</button>
|
||||
<button className="highlight" name="*" onClick={handleClick}>×</button>
|
||||
<button name="4" onClick={handleClick}>4</button>
|
||||
<button name="5" onClick={handleClick}>5</button>
|
||||
<button name="6" onClick={handleClick}>6</button>
|
||||
<button className="highlight" name="-" onClick={handleClick}>–</button>
|
||||
<button name="1" onClick={handleClick}>1</button>
|
||||
<button name="2" onClick={handleClick}>2</button>
|
||||
<button name="3" onClick={handleClick}>3</button>
|
||||
<button className="highlight" name="+" onClick={handleClick}>+</button>
|
||||
<button name="0" onClick={handleClick}>0</button>
|
||||
<button name="." onClick={handleClick}>.</button>
|
||||
<button className="highlight" onClick={calculate} id="result">=</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;`;
|
||||
|
||||
const styles = `
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.calculator {
|
||||
width: 320px;
|
||||
margin: 100px auto;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
font-size: 20px;
|
||||
text-align: right;
|
||||
padding: 0 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
font-size: 18px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: #ff8c00;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.highlight:hover {
|
||||
background-color: #e67e00;
|
||||
}
|
||||
`;
|
||||
|
||||
export function DevCodeViewer({
|
||||
code,
|
||||
showEditor = false,
|
||||
}: {
|
||||
code?: string;
|
||||
showEditor?: boolean;
|
||||
}) {
|
||||
return showEditor ? (
|
||||
<Sandpack
|
||||
options={{
|
||||
showNavigator: true,
|
||||
editorHeight: '80vh',
|
||||
showTabs: true,
|
||||
...sharedOptions,
|
||||
}}
|
||||
files={{
|
||||
// 'App.tsx': code,
|
||||
'App.tsx': App,
|
||||
...sharedFiles,
|
||||
'styles.css': styles,
|
||||
}}
|
||||
{...sharedProps}
|
||||
/>
|
||||
) : (
|
||||
<SandpackProvider
|
||||
files={{
|
||||
// 'App.tsx': code,
|
||||
'App.tsx': App,
|
||||
...sharedFiles,
|
||||
'styles.css': styles,
|
||||
}}
|
||||
className="flex h-full w-full grow flex-col justify-center"
|
||||
options={{ ...sharedOptions }}
|
||||
{...sharedProps}
|
||||
>
|
||||
<SandpackPreview
|
||||
className="flex h-full w-full grow flex-col justify-center p-4 md:pt-16"
|
||||
showOpenInCodeSandbox={false}
|
||||
showRefreshButton={false}
|
||||
/>
|
||||
</SandpackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedProps = {
|
||||
template: 'react-ts',
|
||||
// theme: draculaTheme,
|
||||
customSetup: {
|
||||
dependencies: {
|
||||
'lucide-react': '^0.394.0',
|
||||
'react-router-dom': '^6.11.2',
|
||||
'class-variance-authority': '^0.6.0',
|
||||
clsx: '^1.2.1',
|
||||
'date-fns': '^3.3.1',
|
||||
'tailwind-merge': '^1.9.1',
|
||||
'tailwindcss-animate': '^1.0.5',
|
||||
// recharts: '2.9.0',
|
||||
// '@radix-ui/react-accordion': '^1.2.0',
|
||||
// '@radix-ui/react-alert-dialog': '^1.1.1',
|
||||
// '@radix-ui/react-aspect-ratio': '^1.1.0',
|
||||
// '@radix-ui/react-avatar': '^1.1.0',
|
||||
// '@radix-ui/react-checkbox': '^1.1.1',
|
||||
// '@radix-ui/react-collapsible': '^1.1.0',
|
||||
// '@radix-ui/react-dialog': '^1.1.1',
|
||||
// '@radix-ui/react-dropdown-menu': '^2.1.1',
|
||||
// '@radix-ui/react-hover-card': '^1.1.1',
|
||||
// '@radix-ui/react-label': '^2.1.0',
|
||||
// '@radix-ui/react-menubar': '^1.1.1',
|
||||
// '@radix-ui/react-navigation-menu': '^1.2.0',
|
||||
// '@radix-ui/react-popover': '^1.1.1',
|
||||
// '@radix-ui/react-progress': '^1.1.0',
|
||||
// '@radix-ui/react-radio-group': '^1.2.0',
|
||||
// '@radix-ui/react-select': '^2.1.1',
|
||||
// '@radix-ui/react-separator': '^1.1.0',
|
||||
// '@radix-ui/react-slider': '^1.2.0',
|
||||
// '@radix-ui/react-slot': '^1.1.0',
|
||||
// '@radix-ui/react-switch': '^1.1.0',
|
||||
// '@radix-ui/react-tabs': '^1.1.0',
|
||||
// '@radix-ui/react-toast': '^1.2.1',
|
||||
// '@radix-ui/react-toggle': '^1.1.0',
|
||||
// '@radix-ui/react-toggle-group': '^1.1.0',
|
||||
// '@radix-ui/react-tooltip': '^1.1.2',
|
||||
|
||||
// 'embla-carousel-react': '^8.1.8',
|
||||
// 'react-day-picker': '^8.10.1',
|
||||
// vaul: '^0.9.1',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const sharedOptions = {
|
||||
externalResources: [
|
||||
'https://unpkg.com/@tailwindcss/ui/dist/tailwind-ui.min.css',
|
||||
],
|
||||
};
|
||||
|
||||
const sharedFiles = {
|
||||
'/public/index.html': dedent`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
};
|
||||
37
client/src/components/Artifacts/useDebounceCodeBlock.ts
Normal file
37
client/src/components/Artifacts/useDebounceCodeBlock.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// client/src/hooks/useDebounceCodeBlock.ts
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { codeBlocksState, codeBlockIdsState } from '~/store/artifacts';
|
||||
import type { CodeBlock } from '~/common';
|
||||
|
||||
export function useDebounceCodeBlock() {
|
||||
const setCodeBlocks = useSetRecoilState(codeBlocksState);
|
||||
const setCodeBlockIds = useSetRecoilState(codeBlockIdsState);
|
||||
|
||||
const updateCodeBlock = useCallback((codeBlock: CodeBlock) => {
|
||||
console.log('Updating code block:', codeBlock);
|
||||
setCodeBlocks((prev) => ({
|
||||
...prev,
|
||||
[codeBlock.id]: codeBlock,
|
||||
}));
|
||||
setCodeBlockIds((prev) =>
|
||||
prev.includes(codeBlock.id) ? prev : [...prev, codeBlock.id],
|
||||
);
|
||||
}, [setCodeBlocks, setCodeBlockIds]);
|
||||
|
||||
const debouncedUpdateCodeBlock = useCallback(
|
||||
debounce((codeBlock: CodeBlock) => {
|
||||
updateCodeBlock(codeBlock);
|
||||
}, 25),
|
||||
[updateCodeBlock],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedUpdateCodeBlock.cancel();
|
||||
};
|
||||
}, [debouncedUpdateCodeBlock]);
|
||||
|
||||
return debouncedUpdateCodeBlock;
|
||||
}
|
||||
256
client/src/components/Audio/TTS.tsx
Normal file
256
client/src/components/Audio/TTS.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageAudio } from '~/common';
|
||||
import { useLocalize, useTTSBrowser, useTTSEdge, useTTSExternal } from '~/hooks';
|
||||
import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components/svg';
|
||||
import { useToastContext } from '~/Providers/ToastContext';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export function BrowserTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
|
||||
const localize = useLocalize();
|
||||
const playbackRate = useRecoilValue(store.playbackRate);
|
||||
|
||||
const { toggleSpeech, isSpeaking, isLoading, audioRef } = useTTSBrowser({
|
||||
isLast,
|
||||
index,
|
||||
messageId,
|
||||
content,
|
||||
});
|
||||
|
||||
const renderIcon = (size: string) => {
|
||||
if (isLoading === true) {
|
||||
return <Spinner size={size} />;
|
||||
}
|
||||
|
||||
if (isSpeaking === true) {
|
||||
return <VolumeMuteIcon size={size} />;
|
||||
}
|
||||
|
||||
return <VolumeIcon size={size} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const messageAudio = document.getElementById(`audio-${messageId}`) as HTMLAudioElement | null;
|
||||
if (!messageAudio) {
|
||||
return;
|
||||
}
|
||||
if (playbackRate != null && playbackRate > 0 && messageAudio.playbackRate !== playbackRate) {
|
||||
messageAudio.playbackRate = playbackRate;
|
||||
}
|
||||
}, [audioRef, isSpeaking, playbackRate, messageId]);
|
||||
|
||||
logger.log(
|
||||
'MessageAudio: audioRef.current?.src, audioRef.current',
|
||||
audioRef.current?.src,
|
||||
audioRef.current,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={className}
|
||||
onClickCapture={() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = false;
|
||||
}
|
||||
toggleSpeech();
|
||||
}}
|
||||
type="button"
|
||||
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
|
||||
>
|
||||
{renderIcon('19')}
|
||||
</button>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
preload="none"
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'none',
|
||||
height: '0px',
|
||||
width: '0px',
|
||||
}}
|
||||
src={audioRef.current?.src}
|
||||
onError={(error) => {
|
||||
logger.error('Error fetching audio:', error);
|
||||
}}
|
||||
id={`audio-${messageId}`}
|
||||
muted
|
||||
autoPlay
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EdgeTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
|
||||
const localize = useLocalize();
|
||||
const playbackRate = useRecoilValue(store.playbackRate);
|
||||
const isBrowserSupported = useMemo(
|
||||
() => typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported('audio/mpeg'),
|
||||
[],
|
||||
);
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const { toggleSpeech, isSpeaking, isLoading, audioRef } = useTTSEdge({
|
||||
isLast,
|
||||
index,
|
||||
messageId,
|
||||
content,
|
||||
});
|
||||
|
||||
const renderIcon = (size: string) => {
|
||||
if (isLoading === true) {
|
||||
return <Spinner size={size} />;
|
||||
}
|
||||
|
||||
if (isSpeaking === true) {
|
||||
return <VolumeMuteIcon size={size} />;
|
||||
}
|
||||
|
||||
return <VolumeIcon size={size} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const messageAudio = document.getElementById(`audio-${messageId}`) as HTMLAudioElement | null;
|
||||
if (!messageAudio) {
|
||||
return;
|
||||
}
|
||||
if (playbackRate != null && playbackRate > 0 && messageAudio.playbackRate !== playbackRate) {
|
||||
messageAudio.playbackRate = playbackRate;
|
||||
}
|
||||
}, [audioRef, isSpeaking, playbackRate, messageId]);
|
||||
|
||||
logger.log(
|
||||
'MessageAudio: audioRef.current?.src, audioRef.current',
|
||||
audioRef.current?.src,
|
||||
audioRef.current,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={className}
|
||||
onClickCapture={() => {
|
||||
if (!isBrowserSupported) {
|
||||
showToast({
|
||||
message: localize('com_nav_tts_unsupported_error'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = false;
|
||||
}
|
||||
toggleSpeech();
|
||||
}}
|
||||
type="button"
|
||||
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
|
||||
>
|
||||
{renderIcon('19')}
|
||||
</button>
|
||||
{isBrowserSupported ? (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
preload="none"
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'none',
|
||||
height: '0px',
|
||||
width: '0px',
|
||||
}}
|
||||
src={audioRef.current?.src}
|
||||
onError={(error) => {
|
||||
logger.error('Error fetching audio:', error);
|
||||
}}
|
||||
id={`audio-${messageId}`}
|
||||
muted
|
||||
autoPlay
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
|
||||
const localize = useLocalize();
|
||||
const playbackRate = useRecoilValue(store.playbackRate);
|
||||
|
||||
const { toggleSpeech, isSpeaking, isLoading, audioRef } = useTTSExternal({
|
||||
isLast,
|
||||
index,
|
||||
messageId,
|
||||
content,
|
||||
});
|
||||
|
||||
const renderIcon = (size: string) => {
|
||||
if (isLoading === true) {
|
||||
return <Spinner size={size} />;
|
||||
}
|
||||
|
||||
if (isSpeaking === true) {
|
||||
return <VolumeMuteIcon size={size} />;
|
||||
}
|
||||
|
||||
return <VolumeIcon size={size} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const messageAudio = document.getElementById(`audio-${messageId}`) as HTMLAudioElement | null;
|
||||
if (!messageAudio) {
|
||||
return;
|
||||
}
|
||||
if (playbackRate != null && playbackRate > 0 && messageAudio.playbackRate !== playbackRate) {
|
||||
messageAudio.playbackRate = playbackRate;
|
||||
}
|
||||
}, [audioRef, isSpeaking, playbackRate, messageId]);
|
||||
|
||||
logger.log(
|
||||
'MessageAudio: audioRef.current?.src, audioRef.current',
|
||||
audioRef.current?.src,
|
||||
audioRef.current,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={className}
|
||||
onClickCapture={() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = false;
|
||||
}
|
||||
toggleSpeech();
|
||||
}}
|
||||
type="button"
|
||||
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
|
||||
>
|
||||
{renderIcon('19')}
|
||||
</button>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
preload="none"
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'none',
|
||||
height: '0px',
|
||||
width: '0px',
|
||||
}}
|
||||
src={audioRef.current?.src}
|
||||
onError={(error) => {
|
||||
logger.error('Error fetching audio:', error);
|
||||
}}
|
||||
id={`audio-${messageId}`}
|
||||
muted
|
||||
autoPlay
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
client/src/components/Audio/Voices.tsx
Normal file
94
client/src/components/Audio/Voices.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { Option } from '~/common';
|
||||
import DropdownNoState from '~/components/ui/DropdownNoState';
|
||||
import { useLocalize, useTTSBrowser, useTTSEdge, useTTSExternal } from '~/hooks';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export function EdgeVoiceDropdown() {
|
||||
const localize = useLocalize();
|
||||
const { voices = [] } = useTTSEdge();
|
||||
const [voice, setVoice] = useRecoilState(store.voice);
|
||||
|
||||
const handleVoiceChange = (newValue?: string | Option) => {
|
||||
logger.log('Edge Voice changed:', newValue);
|
||||
const newVoice = typeof newValue === 'string' ? newValue : newValue?.value;
|
||||
if (newVoice != null) {
|
||||
return setVoice(newVoice.toString());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<DropdownNoState
|
||||
key={`edge-voice-dropdown-${voices.length}`}
|
||||
value={voice}
|
||||
options={voices}
|
||||
onChange={handleVoiceChange}
|
||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
anchor="bottom start"
|
||||
testId="EdgeVoiceDropdown"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserVoiceDropdown() {
|
||||
const localize = useLocalize();
|
||||
const { voices = [] } = useTTSBrowser();
|
||||
const [voice, setVoice] = useRecoilState(store.voice);
|
||||
|
||||
const handleVoiceChange = (newValue?: string | Option) => {
|
||||
logger.log('Browser Voice changed:', newValue);
|
||||
const newVoice = typeof newValue === 'string' ? newValue : newValue?.value;
|
||||
if (newVoice != null) {
|
||||
return setVoice(newVoice.toString());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<DropdownNoState
|
||||
key={`browser-voice-dropdown-${voices.length}`}
|
||||
value={voice}
|
||||
options={voices}
|
||||
onChange={handleVoiceChange}
|
||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
anchor="bottom start"
|
||||
testId="BrowserVoiceDropdown"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalVoiceDropdown() {
|
||||
const localize = useLocalize();
|
||||
const { voices = [] } = useTTSExternal();
|
||||
const [voice, setVoice] = useRecoilState(store.voice);
|
||||
|
||||
const handleVoiceChange = (newValue?: string | Option) => {
|
||||
logger.log('External Voice changed:', newValue);
|
||||
const newVoice = typeof newValue === 'string' ? newValue : newValue?.value;
|
||||
if (newVoice != null) {
|
||||
return setVoice(newVoice.toString());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<DropdownNoState
|
||||
key={`external-voice-dropdown-${voices.length}`}
|
||||
value={voice}
|
||||
options={voices}
|
||||
onChange={handleVoiceChange}
|
||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
anchor="bottom start"
|
||||
testId="ExternalVoiceDropdown"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ function Footer({ startupConfig }: { startupConfig: TStartupConfig | null | unde
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="align-end m-4 flex justify-center gap-2">
|
||||
<div className="align-end m-4 flex justify-center gap-2" role="contentinfo">
|
||||
{privacyPolicyRender}
|
||||
{privacyPolicyRender && termsOfServiceRender && (
|
||||
<div className="border-r-[1px] border-gray-300 dark:border-gray-600" />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TRegisterUser, TError } from 'librechat-data-provider';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from './ErrorMessage';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const Registration: React.FC = () => {
|
||||
@@ -21,10 +22,19 @@ const Registration: React.FC = () => {
|
||||
const password = watch('password');
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [countdown, setCountdown] = useState<number>(3);
|
||||
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const token = queryParams.get('token');
|
||||
|
||||
const registerUser = useRegisterUserMutation({
|
||||
onMutate: () => {
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsSubmitting(false);
|
||||
setCountdown(3);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prevCountdown) => {
|
||||
@@ -39,18 +49,13 @@ const Registration: React.FC = () => {
|
||||
}, 1000);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setIsSubmitting(false);
|
||||
if ((error as TError).response?.data?.message) {
|
||||
setErrorMessage((error as TError).response?.data?.message ?? '');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (startupConfig?.registrationEnabled === false) {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [startupConfig, navigate]);
|
||||
|
||||
const renderInput = (id: string, label: string, type: string, validation: object) => (
|
||||
<div className="mb-2">
|
||||
<div className="relative">
|
||||
@@ -110,7 +115,9 @@ const Registration: React.FC = () => {
|
||||
className="mt-6"
|
||||
aria-label="Registration form"
|
||||
method="POST"
|
||||
onSubmit={handleSubmit((data: TRegisterUser) => registerUser.mutate(data))}
|
||||
onSubmit={handleSubmit((data: TRegisterUser) =>
|
||||
registerUser.mutate({ ...data, token: token ?? undefined }),
|
||||
)}
|
||||
>
|
||||
{renderInput('name', 'com_auth_full_name', 'text', {
|
||||
required: localize('com_auth_name_required'),
|
||||
@@ -170,7 +177,7 @@ const Registration: React.FC = () => {
|
||||
aria-label="Submit registration"
|
||||
className="w-full transform rounded-md bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-550 focus:bg-green-550 focus:outline-none disabled:cursor-not-allowed disabled:hover:bg-green-500"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -33,12 +33,15 @@ const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) =>
|
||||
// Define Tailwind CSS classes based on state
|
||||
const baseStyles = 'border border-solid border-gray-300 dark:border-gray-600 transition-colors';
|
||||
|
||||
const pressedStyles = 'bg-blue-200 border-blue-200 dark:bg-blue-900 dark:border-blue-600';
|
||||
const hoverStyles = 'bg-gray-100 dark:bg-gray-700';
|
||||
let dynamicStyles = '';
|
||||
|
||||
return `${baseStyles} ${
|
||||
isPressed && activeButton === id ? pressedStyles : isHovered ? hoverStyles : ''
|
||||
}`;
|
||||
if (isPressed && activeButton === id) {
|
||||
dynamicStyles = 'bg-blue-200 border-blue-200 dark:bg-blue-900 dark:border-blue-600';
|
||||
} else if (isHovered) {
|
||||
dynamicStyles = 'bg-gray-100 dark:bg-gray-700';
|
||||
}
|
||||
|
||||
return `${baseStyles} ${dynamicStyles}`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,30 +1,69 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useRef, Dispatch, SetStateAction } from 'react';
|
||||
import { TConversationTag, TConversation } from 'librechat-data-provider';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { OGDialog, OGDialogTrigger, OGDialogClose } from '~/components/ui/';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { OGDialog } from '~/components/ui';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import BookmarkForm from './BookmarkForm';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Spinner } from '../svg';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
type BookmarkEditDialogProps = {
|
||||
context: string;
|
||||
bookmark?: TConversationTag;
|
||||
conversation?: TConversation;
|
||||
tags?: string[];
|
||||
setTags?: (tags: string[]) => void;
|
||||
trigger: React.ReactNode;
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const BookmarkEditDialog = ({
|
||||
context,
|
||||
bookmark,
|
||||
conversation,
|
||||
tags,
|
||||
setTags,
|
||||
trigger,
|
||||
open,
|
||||
setOpen,
|
||||
}: BookmarkEditDialogProps) => {
|
||||
const localize = useLocalize();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const mutation = useConversationTagMutation({
|
||||
context,
|
||||
tag: bookmark?.tag,
|
||||
options: {
|
||||
onSuccess: (_data, vars) => {
|
||||
showToast({
|
||||
message: bookmark
|
||||
? localize('com_ui_bookmarks_update_success')
|
||||
: localize('com_ui_bookmarks_create_success'),
|
||||
});
|
||||
setOpen(false);
|
||||
logger.log('tag_mutation', 'tags before setting', tags);
|
||||
if (setTags && vars.addToConversation === true) {
|
||||
const newTags = [...(tags || []), vars.tag].filter(
|
||||
(tag) => tag !== undefined,
|
||||
) as string[];
|
||||
setTags(newTags);
|
||||
logger.log('tag_mutation', 'tags after', newTags);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: bookmark
|
||||
? localize('com_ui_bookmarks_update_error')
|
||||
: localize('com_ui_bookmarks_create_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitForm = () => {
|
||||
if (formRef.current) {
|
||||
formRef.current.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
@@ -33,33 +72,28 @@ const BookmarkEditDialog = ({
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>{trigger}</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
title="Bookmark"
|
||||
className="w-11/12 sm:w-1/4"
|
||||
showCloseButton={false}
|
||||
main={
|
||||
<BookmarkForm
|
||||
tags={tags}
|
||||
setOpen={setOpen}
|
||||
mutation={mutation}
|
||||
conversation={conversation}
|
||||
onOpenChange={setOpen}
|
||||
setIsLoading={setIsLoading}
|
||||
bookmark={bookmark}
|
||||
formRef={formRef}
|
||||
setTags={setTags}
|
||||
tags={tags}
|
||||
/>
|
||||
}
|
||||
buttons={
|
||||
<OGDialogClose asChild>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
onClick={handleSubmitForm}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{isLoading ? <Spinner /> : localize('com_ui_save')}
|
||||
</button>
|
||||
</OGDialogClose>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isLoading}
|
||||
onClick={handleSubmitForm}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{mutation.isLoading ? <Spinner /> : localize('com_ui_save')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user