mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
feat: Vision Support + New UI (#1203)
* feat: add timer duration to showToast, show toast for preset selection * refactor: replace old /chat/ route with /c/. e2e tests will fail here * refactor: move typedefs to root of /api/ and add a few to assistant types in TS * refactor: reorganize data-provider imports, fix dependency cycle, strategize new plan to separate react dependent packages * feat: add dataService for uploading images * feat(data-provider): add mutation keys * feat: file resizing and upload * WIP: initial API image handling * fix: catch JSON.parse of localStorage tools * chore: experimental: use module-alias for absolute imports * refactor: change temp_file_id strategy * fix: updating files state by using Map and defining react query callbacks in a way that keeps them during component unmount, initial delete handling * feat: properly handle file deletion * refactor: unexpose complete filepath and resize from server for higher fidelity * fix: make sure resized height, width is saved, catch bad requests * refactor: use absolute imports * fix: prevent setOptions from being called more than once for OpenAIClient, made note to fix for PluginsClient * refactor: import supportsFiles and models vars from schemas * fix: correctly replace temp file id * refactor(BaseClient): use absolute imports, pass message 'opts' to buildMessages method, count tokens for nested objects/arrays * feat: add validateVisionModel to determine if model has vision capabilities * chore(checkBalance): update jsdoc * feat: formatVisionMessage: change message content format dependent on role and image_urls passed * refactor: add usage to File schema, make create and updateFile, correctly set and remove TTL * feat: working vision support TODO: file size, type, amount validations, making sure they are styled right, and making sure you can add images from the clipboard/dragging * feat: clipboard support for uploading images * feat: handle files on drop to screen, refactor top level view code to Presentation component so the useDragHelpers hook has ChatContext * fix(Images): replace uploaded images in place * feat: add filepath validation to protect sensitive files * fix: ensure correct file_ids are push and not the Map key values * fix(ToastContext): type issue * feat: add basic file validation * fix(useDragHelpers): correct context issue with `files` dependency * refactor: consolidate setErrors logic to setError * feat: add dialog Image overlay on image click * fix: close endpoints menu on click * chore: set detail to auto, make note for configuration * fix: react warning (button desc. of button) * refactor: optimize filepath handling, pass file_ids to images for easier re-use * refactor: optimize image file handling, allow re-using files in regen, pass more file metadata in messages * feat: lazy loading images including use of upload preview * fix: SetKeyDialog closing, stopPropagation on Dialog content click * style(EndpointMenuItem): tighten up the style, fix dark theme showing in lightmode, make menu more ux friendly * style: change maxheight of all settings textareas to 138px from 300px * style: better styling for textarea and enclosing buttons * refactor(PresetItems): swap back edit and delete icons * feat: make textarea placeholder dynamic to endpoint * style: show user hover buttons only on hover when message is streaming * fix: ordered list not going past 9, fix css * feat: add User/AI labels; style: hide loading spinner * feat: add back custom footer, change original footer text * feat: dynamic landing icons based on endpoint * chore: comment out assistants route * fix: autoScroll to newest on /c/ view * fix: Export Conversation on new UI * style: match message style of official more closely * ci: fix api jest unit tests, comment out e2e tests for now as they will fail until addressed * feat: more file validation and use blob in preview field, not filepath, to fix temp deletion * feat: filefilter for multer * feat: better AI labels based on custom name, model, and endpoint instead of `ChatGPT`
This commit is contained in:
parent
345f4b2e85
commit
317cdd3f77
113 changed files with 2680 additions and 675 deletions
130
.github/workflows/playwright.yml
vendored
130
.github/workflows/playwright.yml
vendored
|
@ -1,72 +1,72 @@
|
|||
name: Playwright Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- release/*
|
||||
paths:
|
||||
- 'api/**'
|
||||
- 'client/**'
|
||||
- 'packages/**'
|
||||
- 'e2e/**'
|
||||
jobs:
|
||||
tests_e2e:
|
||||
name: Run Playwright tests
|
||||
if: github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat'
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_ENV: CI
|
||||
CI: true
|
||||
SEARCH: false
|
||||
BINGAI_TOKEN: user_provided
|
||||
CHATGPT_TOKEN: user_provided
|
||||
MONGO_URI: ${{ secrets.MONGO_URI }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
|
||||
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
|
||||
CREDS_KEY: ${{ secrets.CREDS_KEY }}
|
||||
CREDS_IV: ${{ secrets.CREDS_IV }}
|
||||
DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }}
|
||||
DOMAIN_SERVER: ${{ secrets.DOMAIN_SERVER }}
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 # Skip downloading during npm install
|
||||
PLAYWRIGHT_BROWSERS_PATH: 0 # Places binaries to node_modules/@playwright/test
|
||||
TITLE_CONVO: false
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
# name: Playwright Tests
|
||||
# on:
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - main
|
||||
# - dev
|
||||
# - release/*
|
||||
# paths:
|
||||
# - 'api/**'
|
||||
# - 'client/**'
|
||||
# - 'packages/**'
|
||||
# - 'e2e/**'
|
||||
# jobs:
|
||||
# tests_e2e:
|
||||
# name: Run Playwright tests
|
||||
# if: github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat'
|
||||
# timeout-minutes: 60
|
||||
# runs-on: ubuntu-latest
|
||||
# env:
|
||||
# NODE_ENV: CI
|
||||
# CI: true
|
||||
# SEARCH: false
|
||||
# BINGAI_TOKEN: user_provided
|
||||
# CHATGPT_TOKEN: user_provided
|
||||
# MONGO_URI: ${{ secrets.MONGO_URI }}
|
||||
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
|
||||
# E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
|
||||
# JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
# JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
|
||||
# CREDS_KEY: ${{ secrets.CREDS_KEY }}
|
||||
# CREDS_IV: ${{ secrets.CREDS_IV }}
|
||||
# DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }}
|
||||
# DOMAIN_SERVER: ${{ secrets.DOMAIN_SERVER }}
|
||||
# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 # Skip downloading during npm install
|
||||
# PLAYWRIGHT_BROWSERS_PATH: 0 # Places binaries to node_modules/@playwright/test
|
||||
# TITLE_CONVO: false
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# - uses: actions/setup-node@v3
|
||||
# with:
|
||||
# node-version: 18
|
||||
# cache: 'npm'
|
||||
|
||||
- name: Install global dependencies
|
||||
run: npm ci
|
||||
# - name: Install global dependencies
|
||||
# run: npm ci
|
||||
|
||||
# - name: Remove sharp dependency
|
||||
# run: rm -rf node_modules/sharp
|
||||
# # - name: Remove sharp dependency
|
||||
# # run: rm -rf node_modules/sharp
|
||||
|
||||
# - name: Install sharp with linux dependencies
|
||||
# run: cd api && SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install --arch=x64 --platform=linux --libc=glibc sharp
|
||||
# # - name: Install sharp with linux dependencies
|
||||
# # run: cd api && SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install --arch=x64 --platform=linux --libc=glibc sharp
|
||||
|
||||
- name: Build Client
|
||||
run: npm run frontend
|
||||
# - name: Build Client
|
||||
# run: npm run frontend
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
npx playwright install-deps
|
||||
npm install -D @playwright/test@latest
|
||||
npx playwright install chromium
|
||||
# - name: Install Playwright
|
||||
# run: |
|
||||
# npx playwright install-deps
|
||||
# npm install -D @playwright/test@latest
|
||||
# npx playwright install chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npm run e2e:ci
|
||||
# - name: Run Playwright tests
|
||||
# run: npm run e2e:ci
|
||||
|
||||
- name: Upload playwright report
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report/
|
||||
retention-days: 30
|
||||
# - name: Upload playwright report
|
||||
# uses: actions/upload-artifact@v3
|
||||
# if: always()
|
||||
# with:
|
||||
# name: playwright-report
|
||||
# path: e2e/playwright-report/
|
||||
# retention-days: 30
|
|
@ -1,8 +1,8 @@
|
|||
const crypto = require('crypto');
|
||||
const TextStream = require('./TextStream');
|
||||
const { getConvo, getMessages, saveMessage, updateMessage, saveConvo } = require('../../models');
|
||||
const { addSpaceIfNeeded, isEnabled } = require('../../server/utils');
|
||||
const checkBalance = require('../../models/checkBalance');
|
||||
const { getConvo, getMessages, saveMessage, updateMessage, saveConvo } = require('~/models');
|
||||
const { addSpaceIfNeeded, isEnabled } = require('~/server/utils');
|
||||
const checkBalance = require('~/models/checkBalance');
|
||||
|
||||
class BaseClient {
|
||||
constructor(apiKey, options = {}) {
|
||||
|
@ -62,7 +62,7 @@ class BaseClient {
|
|||
}
|
||||
|
||||
async setMessageOptions(opts = {}) {
|
||||
if (opts && typeof opts === 'object') {
|
||||
if (opts && opts.replaceOptions) {
|
||||
this.setOptions(opts);
|
||||
}
|
||||
|
||||
|
@ -417,6 +417,7 @@ class BaseClient {
|
|||
// this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation
|
||||
isEdited ? head : userMessage.messageId,
|
||||
this.getBuildMessagesOptions(opts),
|
||||
opts,
|
||||
);
|
||||
|
||||
if (tokenCountMap) {
|
||||
|
@ -636,14 +637,27 @@ class BaseClient {
|
|||
tokensPerName = -1;
|
||||
}
|
||||
|
||||
const processValue = (value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
for (let [nestedKey, nestedValue] of Object.entries(value)) {
|
||||
if (nestedKey === 'image_url' || nestedValue === 'image_url') {
|
||||
continue;
|
||||
}
|
||||
processValue(nestedValue);
|
||||
}
|
||||
} else {
|
||||
numTokens += this.getTokenCount(value);
|
||||
}
|
||||
};
|
||||
|
||||
let numTokens = tokensPerMessage;
|
||||
for (let [key, value] of Object.entries(message)) {
|
||||
numTokens += this.getTokenCount(value);
|
||||
processValue(value);
|
||||
|
||||
if (key === 'name') {
|
||||
numTokens += tokensPerName;
|
||||
}
|
||||
}
|
||||
|
||||
return numTokens;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
const OpenAI = require('openai');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
|
||||
const { getModelMaxTokens, genAzureChatCompletion, extractBaseURL } = require('../../utils');
|
||||
const { encodeAndFormat, validateVisionModel } = require('~/server/services/Files/images');
|
||||
const { getModelMaxTokens, genAzureChatCompletion, extractBaseURL } = require('~/utils');
|
||||
const { truncateText, formatMessage, CUT_OFF_PROMPT } = require('./prompts');
|
||||
const spendTokens = require('../../models/spendTokens');
|
||||
const { getResponseSender, EModelEndpoint } = require('~/server/routes/endpoints/schemas');
|
||||
const { handleOpenAIErrors } = require('./tools/util');
|
||||
const { isEnabled } = require('../../server/utils');
|
||||
const spendTokens = require('~/models/spendTokens');
|
||||
const { createLLM, RunManager } = require('./llm');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const ChatGPTClient = require('./ChatGPTClient');
|
||||
const { summaryBuffer } = require('./memory');
|
||||
const { runTitleChain } = require('./chains');
|
||||
|
@ -24,7 +26,6 @@ class OpenAIClient extends BaseClient {
|
|||
this.ChatGPTClient = new ChatGPTClient();
|
||||
this.buildPrompt = this.ChatGPTClient.buildPrompt.bind(this);
|
||||
this.getCompletion = this.ChatGPTClient.getCompletion.bind(this);
|
||||
this.sender = options.sender ?? 'ChatGPT';
|
||||
this.contextStrategy = options.contextStrategy
|
||||
? options.contextStrategy.toLowerCase()
|
||||
: 'discard';
|
||||
|
@ -33,6 +34,7 @@ class OpenAIClient extends BaseClient {
|
|||
this.setOptions(options);
|
||||
}
|
||||
|
||||
// TODO: PluginsClient calls this 3x, unneeded
|
||||
setOptions(options) {
|
||||
if (this.options && !this.options.replaceOptions) {
|
||||
this.options.modelOptions = {
|
||||
|
@ -53,6 +55,7 @@ class OpenAIClient extends BaseClient {
|
|||
}
|
||||
|
||||
const modelOptions = this.options.modelOptions || {};
|
||||
|
||||
if (!this.modelOptions) {
|
||||
this.modelOptions = {
|
||||
...modelOptions,
|
||||
|
@ -72,6 +75,14 @@ class OpenAIClient extends BaseClient {
|
|||
};
|
||||
}
|
||||
|
||||
if (this.options.attachments && !validateVisionModel(this.modelOptions.model)) {
|
||||
this.modelOptions.model = 'gpt-4-vision-preview';
|
||||
}
|
||||
|
||||
if (validateVisionModel(this.modelOptions.model)) {
|
||||
delete this.modelOptions.stop;
|
||||
}
|
||||
|
||||
const { OPENROUTER_API_KEY, OPENAI_FORCE_PROMPT } = process.env ?? {};
|
||||
if (OPENROUTER_API_KEY && !this.azure) {
|
||||
this.apiKey = OPENROUTER_API_KEY;
|
||||
|
@ -127,12 +138,20 @@ class OpenAIClient extends BaseClient {
|
|||
);
|
||||
}
|
||||
|
||||
this.sender =
|
||||
this.options.sender ??
|
||||
getResponseSender({
|
||||
model: this.modelOptions.model,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
chatGptLabel: this.options.chatGptLabel,
|
||||
});
|
||||
|
||||
this.userLabel = this.options.userLabel || 'User';
|
||||
this.chatGptLabel = this.options.chatGptLabel || 'Assistant';
|
||||
|
||||
this.setupTokens();
|
||||
|
||||
if (!this.modelOptions.stop) {
|
||||
if (!this.modelOptions.stop && !validateVisionModel(this.modelOptions.model)) {
|
||||
const stopTokens = [this.startToken];
|
||||
if (this.endToken && this.endToken !== this.startToken) {
|
||||
stopTokens.push(this.endToken);
|
||||
|
@ -284,6 +303,7 @@ class OpenAIClient extends BaseClient {
|
|||
messages,
|
||||
parentMessageId,
|
||||
{ isChatCompletion = false, promptPrefix = null },
|
||||
opts,
|
||||
) {
|
||||
let orderedMessages = this.constructor.getMessagesForConversation({
|
||||
messages,
|
||||
|
@ -316,6 +336,17 @@ class OpenAIClient extends BaseClient {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.options.attachments) {
|
||||
const attachments = await this.options.attachments;
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments.filter((file) => file.type.includes('image')),
|
||||
);
|
||||
|
||||
orderedMessages[orderedMessages.length - 1].image_urls = image_urls;
|
||||
this.options.attachments = files;
|
||||
}
|
||||
|
||||
const formattedMessages = orderedMessages.map((message, i) => {
|
||||
const formattedMessage = formatMessage({
|
||||
message,
|
||||
|
@ -350,8 +381,8 @@ class OpenAIClient extends BaseClient {
|
|||
result.tokenCountMap = tokenCountMap;
|
||||
}
|
||||
|
||||
if (promptTokens >= 0 && typeof this.options.getReqData === 'function') {
|
||||
this.options.getReqData({ promptTokens });
|
||||
if (promptTokens >= 0 && typeof opts?.getReqData === 'function') {
|
||||
opts.getReqData({ promptTokens });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
@ -730,6 +761,10 @@ ${convo}
|
|||
opts.httpAgent = new HttpsProxyAgent(this.options.proxy);
|
||||
}
|
||||
|
||||
if (validateVisionModel(modelOptions.model)) {
|
||||
modelOptions.max_tokens = 4000;
|
||||
}
|
||||
|
||||
let chatCompletion;
|
||||
const openai = new OpenAI({
|
||||
apiKey: this.apiKey,
|
||||
|
|
|
@ -1,5 +1,21 @@
|
|||
const { HumanMessage, AIMessage, SystemMessage } = require('langchain/schema');
|
||||
|
||||
/**
|
||||
* Formats a message to OpenAI Vision API payload format.
|
||||
*
|
||||
* @param {Object} params - The parameters for formatting.
|
||||
* @param {Object} params.message - The message object to format.
|
||||
* @param {string} [params.message.role] - The role of the message sender (must be 'user').
|
||||
* @param {string} [params.message.content] - The text content of the message.
|
||||
* @param {Array<string>} [params.image_urls] - The image_urls to attach to the message.
|
||||
* @returns {(Object)} - The formatted message.
|
||||
*/
|
||||
const formatVisionMessage = ({ message, image_urls }) => {
|
||||
message.content = [{ type: 'text', text: message.content }, ...image_urls];
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a message to OpenAI payload format based on the provided options.
|
||||
*
|
||||
|
@ -10,6 +26,7 @@ const { HumanMessage, AIMessage, SystemMessage } = require('langchain/schema');
|
|||
* @param {string} [params.message.sender] - The sender of the message.
|
||||
* @param {string} [params.message.text] - The text content of the message.
|
||||
* @param {string} [params.message.content] - The content of the message.
|
||||
* @param {Array<string>} [params.message.image_urls] - The image_urls attached to the message for Vision API.
|
||||
* @param {string} [params.userName] - The name of the user.
|
||||
* @param {string} [params.assistantName] - The name of the assistant.
|
||||
* @param {boolean} [params.langChain=false] - Whether to return a LangChain message object.
|
||||
|
@ -32,6 +49,11 @@ const formatMessage = ({ message, userName, assistantName, langChain = false })
|
|||
content,
|
||||
};
|
||||
|
||||
const { image_urls } = message;
|
||||
if (Array.isArray(image_urls) && image_urls.length > 0 && role === 'user') {
|
||||
return formatVisionMessage({ message: formattedMessage, image_urls: message.image_urls });
|
||||
}
|
||||
|
||||
if (_name) {
|
||||
formattedMessage.name = _name;
|
||||
}
|
||||
|
|
|
@ -529,9 +529,9 @@ describe('BaseClient', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('setOptions is called with the correct arguments', async () => {
|
||||
test('setOptions is called with the correct arguments only when replaceOptions is set to true', async () => {
|
||||
TestClient.setOptions = jest.fn();
|
||||
const opts = { conversationId: '123', parentMessageId: '456' };
|
||||
const opts = { conversationId: '123', parentMessageId: '456', replaceOptions: true };
|
||||
await TestClient.sendMessage('Hello, world!', opts);
|
||||
expect(TestClient.setOptions).toHaveBeenCalledWith(opts);
|
||||
TestClient.setOptions.mockClear();
|
||||
|
|
6
api/config.js
Normal file
6
api/config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
publicPath: path.resolve(__dirname, '..', 'client', 'public'),
|
||||
imageOutput: path.resolve(__dirname, '..', 'client', 'public', 'images'),
|
||||
};
|
|
@ -4,4 +4,7 @@ module.exports = {
|
|||
roots: ['<rootDir>'],
|
||||
coverageDirectory: 'coverage',
|
||||
setupFiles: ['./test/jestSetup.js', './test/__mocks__/KeyvMongo.js'],
|
||||
moduleNameMapper: {
|
||||
'~/(.*)': '<rootDir>/$1',
|
||||
},
|
||||
};
|
||||
|
|
96
api/models/File.js
Normal file
96
api/models/File.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
const mongoose = require('mongoose');
|
||||
const fileSchema = require('./schema/fileSchema');
|
||||
|
||||
const File = mongoose.model('File', fileSchema);
|
||||
|
||||
/**
|
||||
* Finds a file by its file_id with additional query options.
|
||||
* @param {string} file_id - The unique identifier of the file.
|
||||
* @param {object} options - Query options for filtering, projection, etc.
|
||||
* @returns {Promise<MongoFile>} A promise that resolves to the file document or null.
|
||||
*/
|
||||
const findFileById = async (file_id, options = {}) => {
|
||||
return await File.findOne({ file_id, ...options }).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves files matching a given filter.
|
||||
* @param {Object} filter - The filter criteria to apply.
|
||||
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
|
||||
*/
|
||||
const getFiles = async (filter) => {
|
||||
return await File.find(filter).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new file with a TTL of 1 hour.
|
||||
* @param {Object} data - The file data to be created, must contain file_id.
|
||||
* @returns {Promise<MongoFile>} A promise that resolves to the created file document.
|
||||
*/
|
||||
const createFile = async (data) => {
|
||||
const fileData = {
|
||||
...data,
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
};
|
||||
return await File.findOneAndUpdate({ file_id: data.file_id }, fileData, {
|
||||
new: true,
|
||||
upsert: true,
|
||||
}).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a file identified by file_id with new data and removes the TTL.
|
||||
* @param {Object} data - The data to update, must contain file_id.
|
||||
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
|
||||
*/
|
||||
const updateFile = async (data) => {
|
||||
const { file_id, ...update } = data;
|
||||
const updateOperation = {
|
||||
$set: update,
|
||||
$unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
|
||||
};
|
||||
return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Increments the usage of a file identified by file_id.
|
||||
* @param {Object} data - The data to update, must contain file_id and the increment value for usage.
|
||||
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
|
||||
*/
|
||||
const updateFileUsage = async (data) => {
|
||||
const { file_id, inc = 1 } = data;
|
||||
const updateOperation = {
|
||||
$inc: { usage: inc },
|
||||
$unset: { expiresAt: '' },
|
||||
};
|
||||
return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a file identified by file_id.
|
||||
* @param {string} file_id - The unique identifier of the file to delete.
|
||||
* @returns {Promise<MongoFile>} A promise that resolves to the deleted file document or null.
|
||||
*/
|
||||
const deleteFile = async (file_id) => {
|
||||
return await File.findOneAndDelete({ file_id }).lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes multiple files identified by an array of file_ids.
|
||||
* @param {Array<string>} file_ids - The unique identifiers of the files to delete.
|
||||
* @returns {Promise<Object>} A promise that resolves to the result of the deletion operation.
|
||||
*/
|
||||
const deleteFiles = async (file_ids) => {
|
||||
return await File.deleteMany({ file_id: { $in: file_ids } });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
File,
|
||||
findFileById,
|
||||
getFiles,
|
||||
createFile,
|
||||
updateFile,
|
||||
updateFileUsage,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
};
|
|
@ -18,6 +18,7 @@ module.exports = {
|
|||
error,
|
||||
unfinished,
|
||||
cancelled,
|
||||
files,
|
||||
isEdited = false,
|
||||
finish_reason = null,
|
||||
tokenCount = null,
|
||||
|
@ -30,29 +31,31 @@ module.exports = {
|
|||
if (!validConvoId.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = {
|
||||
user,
|
||||
messageId: newMessageId || messageId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
sender,
|
||||
text,
|
||||
isCreatedByUser,
|
||||
isEdited,
|
||||
finish_reason,
|
||||
error,
|
||||
unfinished,
|
||||
cancelled,
|
||||
tokenCount,
|
||||
plugin,
|
||||
plugins,
|
||||
model,
|
||||
};
|
||||
|
||||
if (files) {
|
||||
update.files = files;
|
||||
}
|
||||
// may also need to update the conversation here
|
||||
await Message.findOneAndUpdate(
|
||||
{ messageId },
|
||||
{
|
||||
user,
|
||||
messageId: newMessageId || messageId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
sender,
|
||||
text,
|
||||
isCreatedByUser,
|
||||
isEdited,
|
||||
finish_reason,
|
||||
error,
|
||||
unfinished,
|
||||
cancelled,
|
||||
tokenCount,
|
||||
plugin,
|
||||
plugins,
|
||||
model,
|
||||
},
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
await Message.findOneAndUpdate({ messageId }, update, { upsert: true, new: true });
|
||||
|
||||
return {
|
||||
messageId,
|
||||
|
|
|
@ -7,8 +7,8 @@ const { logViolation } = require('../cache');
|
|||
* @async
|
||||
* @function
|
||||
* @param {Object} params - The function parameters.
|
||||
* @param {Object} params.req - The Express request object.
|
||||
* @param {Object} params.res - The Express response object.
|
||||
* @param {Express.Request} params.req - The Express request object.
|
||||
* @param {Express.Response} params.res - The Express response object.
|
||||
* @param {Object} params.txData - The transaction data.
|
||||
* @param {string} params.txData.user - The user ID or identifier.
|
||||
* @param {('prompt' | 'completion')} params.txData.tokenType - The type of token.
|
||||
|
|
|
@ -7,6 +7,15 @@ const {
|
|||
} = require('./Message');
|
||||
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
|
||||
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
|
||||
const {
|
||||
findFileById,
|
||||
createFile,
|
||||
updateFile,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
getFiles,
|
||||
updateFileUsage,
|
||||
} = require('./File');
|
||||
const Key = require('./Key');
|
||||
const User = require('./User');
|
||||
const Session = require('./Session');
|
||||
|
@ -35,4 +44,12 @@ module.exports = {
|
|||
getPresets,
|
||||
savePreset,
|
||||
deletePresets,
|
||||
|
||||
findFileById,
|
||||
createFile,
|
||||
updateFile,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
getFiles,
|
||||
updateFileUsage,
|
||||
};
|
||||
|
|
79
api/models/schema/fileSchema.js
Normal file
79
api/models/schema/fileSchema.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* @typedef {Object} MongoFile
|
||||
* @property {mongoose.Schema.Types.ObjectId} user - User ID
|
||||
* @property {string} [conversationId] - Optional conversation ID
|
||||
* @property {string} file_id - File identifier
|
||||
* @property {string} [temp_file_id] - Temporary File identifier
|
||||
* @property {number} bytes - Size of the file in bytes
|
||||
* @property {string} filename - Name of the file
|
||||
* @property {string} filepath - Location of the file
|
||||
* @property {'file'} object - Type of object, always 'file'
|
||||
* @property {string} type - Type of file
|
||||
* @property {number} usage - Number of uses of the file
|
||||
* @property {number} [width] - Optional width of the file
|
||||
* @property {number} [height] - Optional height of the file
|
||||
* @property {Date} [expiresAt] - Optional height of the file
|
||||
*/
|
||||
const fileSchema = mongoose.Schema(
|
||||
{
|
||||
user: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true,
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: String,
|
||||
ref: 'Conversation',
|
||||
index: true,
|
||||
},
|
||||
file_id: {
|
||||
type: String,
|
||||
// required: true,
|
||||
index: true,
|
||||
},
|
||||
temp_file_id: {
|
||||
type: String,
|
||||
// required: true,
|
||||
},
|
||||
bytes: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
usage: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filepath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
object: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'file',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
expires: 3600,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = fileSchema;
|
|
@ -85,6 +85,7 @@ const messageSchema = mongoose.Schema(
|
|||
select: false,
|
||||
default: false,
|
||||
},
|
||||
files: [{ type: mongoose.Schema.Types.Mixed }],
|
||||
plugin: {
|
||||
latest: {
|
||||
type: String,
|
||||
|
|
|
@ -16,6 +16,12 @@
|
|||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"_moduleAliases": {
|
||||
"~": "."
|
||||
},
|
||||
"imports": {
|
||||
"~/*": "./*"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/danny-avila/LibreChat/issues"
|
||||
},
|
||||
|
@ -48,7 +54,9 @@
|
|||
"langchain": "^0.0.186",
|
||||
"lodash": "^4.17.21",
|
||||
"meilisearch": "^0.33.0",
|
||||
"module-alias": "^2.2.3",
|
||||
"mongoose": "^7.1.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodejs-gpt": "^1.37.4",
|
||||
"nodemailer": "^6.9.4",
|
||||
"openai": "^4.16.1",
|
||||
|
|
|
@ -8,7 +8,7 @@ const {
|
|||
userProvidedOpenAI,
|
||||
palmKey,
|
||||
openAI,
|
||||
assistant,
|
||||
// assistant,
|
||||
azureOpenAI,
|
||||
bingAI,
|
||||
chatGPTBrowser,
|
||||
|
@ -57,7 +57,7 @@ async function endpointController(req, res) {
|
|||
res.send(
|
||||
JSON.stringify({
|
||||
[EModelEndpoint.openAI]: openAI,
|
||||
[EModelEndpoint.assistant]: assistant,
|
||||
// [EModelEndpoint.assistant]: assistant,
|
||||
[EModelEndpoint.azureOpenAI]: azureOpenAI,
|
||||
[EModelEndpoint.google]: google,
|
||||
[EModelEndpoint.bingAI]: bingAI,
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
const express = require('express');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { connectDb, indexSync } = require('../lib/db');
|
||||
const path = require('path');
|
||||
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
||||
const cors = require('cors');
|
||||
const routes = require('./routes');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const { connectDb, indexSync } = require('../lib/db');
|
||||
const config = require('../config');
|
||||
const routes = require('./routes');
|
||||
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN } = process.env ?? {};
|
||||
|
||||
const port = Number(PORT) || 3080;
|
||||
|
@ -20,6 +23,7 @@ const startServer = async () => {
|
|||
await indexSync();
|
||||
|
||||
const app = express();
|
||||
app.locals.config = config;
|
||||
|
||||
// Middleware
|
||||
app.use(errorController);
|
||||
|
@ -65,6 +69,7 @@ const startServer = async () => {
|
|||
app.use('/api/plugins', routes.plugins);
|
||||
app.use('/api/config', routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
app.use('/api/files', routes.files);
|
||||
|
||||
// Static files
|
||||
app.get('/*', function (req, res) {
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
const openAI = require('../routes/endpoints/openAI');
|
||||
const gptPlugins = require('../routes/endpoints/gptPlugins');
|
||||
const anthropic = require('../routes/endpoints/anthropic');
|
||||
const { parseConvo } = require('../routes/endpoints/schemas');
|
||||
const openAI = require('~/server/routes/endpoints/openAI');
|
||||
const gptPlugins = require('~/server/routes/endpoints/gptPlugins');
|
||||
const anthropic = require('~/server/routes/endpoints/anthropic');
|
||||
const { parseConvo, EModelEndpoint } = require('~/server/routes/endpoints/schemas');
|
||||
const { processFiles } = require('~/server/services/Files');
|
||||
|
||||
const buildFunction = {
|
||||
openAI: openAI.buildOptions,
|
||||
azureOpenAI: openAI.buildOptions,
|
||||
gptPlugins: gptPlugins.buildOptions,
|
||||
anthropic: anthropic.buildOptions,
|
||||
[EModelEndpoint.openAI]: openAI.buildOptions,
|
||||
[EModelEndpoint.azureOpenAI]: openAI.buildOptions,
|
||||
[EModelEndpoint.gptPlugins]: gptPlugins.buildOptions,
|
||||
[EModelEndpoint.anthropic]: anthropic.buildOptions,
|
||||
};
|
||||
|
||||
function buildEndpointOption(req, res, next) {
|
||||
const { endpoint } = req.body;
|
||||
const parsedBody = parseConvo(endpoint, req.body);
|
||||
req.body.endpointOption = buildFunction[endpoint](endpoint, parsedBody);
|
||||
if (req.body.files) {
|
||||
// hold the promise
|
||||
req.body.endpointOption.attachments = processFiles(req.body.files);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getResponseSender } = require('../endpoints/schemas');
|
||||
const { sendMessage, createOnProgress } = require('../../utils');
|
||||
const { addTitle, initializeClient } = require('../endpoints/openAI');
|
||||
const { saveMessage, getConvoTitle, getConvo } = require('../../../models');
|
||||
const { sendMessage, createOnProgress } = require('~/server/utils');
|
||||
const { saveMessage, getConvoTitle, getConvo } = require('~/models');
|
||||
const { getResponseSender } = require('~/server/routes/endpoints/schemas');
|
||||
const { addTitle, initializeClient } = require('~/server/routes/endpoints/openAI');
|
||||
const {
|
||||
handleAbort,
|
||||
createAbortController,
|
||||
|
@ -11,7 +11,7 @@ const {
|
|||
setHeaders,
|
||||
validateEndpoint,
|
||||
buildEndpointOption,
|
||||
} = require('../../middleware');
|
||||
} = require('~/server/middleware');
|
||||
|
||||
router.post('/abort', handleAbort());
|
||||
|
||||
|
@ -93,8 +93,7 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req,
|
|||
|
||||
try {
|
||||
const { client } = await initializeClient({ req, res, endpointOption });
|
||||
|
||||
let response = await client.sendMessage(text, {
|
||||
const messageOptions = {
|
||||
user,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
|
@ -108,7 +107,9 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req,
|
|||
text,
|
||||
parentMessageId: overrideParentMessageId || userMessageId,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
let response = await client.sendMessage(text, messageOptions);
|
||||
|
||||
if (overrideParentMessageId) {
|
||||
response.parentMessageId = overrideParentMessageId;
|
||||
|
@ -118,7 +119,10 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req,
|
|||
response = { ...response, ...metadata };
|
||||
}
|
||||
|
||||
await saveMessage({ ...response, user });
|
||||
if (client.options.attachments) {
|
||||
userMessage.files = client.options.attachments;
|
||||
delete userMessage.image_urls;
|
||||
}
|
||||
|
||||
sendMessage(res, {
|
||||
title: await getConvoTitle(user, conversationId),
|
||||
|
@ -129,6 +133,9 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req,
|
|||
});
|
||||
res.end();
|
||||
|
||||
await saveMessage({ ...response, user });
|
||||
await saveMessage(userMessage);
|
||||
|
||||
if (parentMessageId === '00000000-0000-0000-0000-000000000000' && newConvo) {
|
||||
addTitle(req, {
|
||||
text,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const { OpenAIClient } = require('../../../../app');
|
||||
const { isEnabled } = require('../../../utils');
|
||||
const { getAzureCredentials } = require('../../../../utils');
|
||||
const { getUserKey, checkUserKeyExpiry } = require('../../../services/UserService');
|
||||
const { OpenAIClient } = require('~/app');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { getAzureCredentials } = require('~/utils');
|
||||
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
|
||||
const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
const {
|
||||
|
|
|
@ -11,6 +11,41 @@ const EModelEndpoint = {
|
|||
assistant: 'assistant',
|
||||
};
|
||||
|
||||
const alternateName = {
|
||||
[EModelEndpoint.openAI]: 'OpenAI',
|
||||
[EModelEndpoint.assistant]: 'Assistants',
|
||||
[EModelEndpoint.azureOpenAI]: 'Azure OpenAI',
|
||||
[EModelEndpoint.bingAI]: 'Bing',
|
||||
[EModelEndpoint.chatGPTBrowser]: 'ChatGPT',
|
||||
[EModelEndpoint.gptPlugins]: 'Plugins',
|
||||
[EModelEndpoint.google]: 'PaLM',
|
||||
[EModelEndpoint.anthropic]: 'Anthropic',
|
||||
};
|
||||
|
||||
const supportsFiles = {
|
||||
[EModelEndpoint.openAI]: true,
|
||||
[EModelEndpoint.assistant]: true,
|
||||
};
|
||||
|
||||
const openAIModels = [
|
||||
'gpt-3.5-turbo-16k-0613',
|
||||
'gpt-3.5-turbo-16k',
|
||||
'gpt-4-1106-preview',
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-3.5-turbo-1106',
|
||||
'gpt-4-vision-preview',
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo-instruct-0914',
|
||||
'gpt-3.5-turbo-0613',
|
||||
'gpt-3.5-turbo-0301',
|
||||
'gpt-3.5-turbo-instruct',
|
||||
'gpt-4-0613',
|
||||
'text-davinci-003',
|
||||
'gpt-4-0314',
|
||||
];
|
||||
|
||||
const visionModels = ['gpt-4-vision', 'llava-13b'];
|
||||
|
||||
const eModelEndpointSchema = z.nativeEnum(EModelEndpoint);
|
||||
|
||||
const tPluginAuthConfigSchema = z.object({
|
||||
|
@ -321,7 +356,7 @@ const parseConvo = (endpoint, conversation, possibleValues) => {
|
|||
};
|
||||
|
||||
const getResponseSender = (endpointOption) => {
|
||||
const { endpoint, chatGptLabel, modelLabel, jailbreak } = endpointOption;
|
||||
const { model, endpoint, chatGptLabel, modelLabel, jailbreak } = endpointOption;
|
||||
|
||||
if (
|
||||
[
|
||||
|
@ -331,7 +366,14 @@ const getResponseSender = (endpointOption) => {
|
|||
EModelEndpoint.chatGPTBrowser,
|
||||
].includes(endpoint)
|
||||
) {
|
||||
return chatGptLabel ?? 'ChatGPT';
|
||||
if (chatGptLabel) {
|
||||
return chatGptLabel;
|
||||
} else if (model && model.includes('gpt-3')) {
|
||||
return 'GPT-3.5';
|
||||
} else if (model && model.includes('gpt-4')) {
|
||||
return 'GPT-4';
|
||||
}
|
||||
return alternateName[endpoint] ?? 'ChatGPT';
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.bingAI) {
|
||||
|
@ -353,4 +395,8 @@ module.exports = {
|
|||
parseConvo,
|
||||
getResponseSender,
|
||||
EModelEndpoint,
|
||||
supportsFiles,
|
||||
openAIModels,
|
||||
visionModels,
|
||||
alternateName,
|
||||
};
|
||||
|
|
58
api/server/routes/files/files.js
Normal file
58
api/server/routes/files/files.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
const { z } = require('zod');
|
||||
const fs = require('fs').promises;
|
||||
const express = require('express');
|
||||
const { deleteFiles } = require('~/models');
|
||||
const path = require('path');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const isUUID = z.string().uuid();
|
||||
|
||||
const isValidPath = (base, subfolder, filepath) => {
|
||||
const normalizedBase = path.resolve(base, subfolder, 'temp');
|
||||
const normalizedFilepath = path.resolve(filepath);
|
||||
return normalizedFilepath.startsWith(normalizedBase);
|
||||
};
|
||||
|
||||
const deleteFile = async (req, file) => {
|
||||
const { publicPath } = req.app.locals.config;
|
||||
const parts = file.filepath.split(path.sep);
|
||||
const subfolder = parts[1];
|
||||
const filepath = path.join(publicPath, file.filepath);
|
||||
|
||||
if (!isValidPath(publicPath, subfolder, filepath)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
await fs.unlink(filepath);
|
||||
};
|
||||
|
||||
router.delete('/', async (req, res) => {
|
||||
try {
|
||||
const { files: _files } = req.body;
|
||||
const files = _files.filter((file) => {
|
||||
if (!file.file_id) {
|
||||
return false;
|
||||
}
|
||||
if (!file.filepath) {
|
||||
return false;
|
||||
}
|
||||
return isUUID.safeParse(file.file_id).success;
|
||||
});
|
||||
|
||||
const file_ids = files.map((file) => file.file_id);
|
||||
const promises = [];
|
||||
promises.push(await deleteFiles(file_ids));
|
||||
for (const file of files) {
|
||||
promises.push(deleteFile(req, file));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
res.status(200).json({ message: 'Files deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting files:', error);
|
||||
res.status(400).json({ message: 'Error in request', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
58
api/server/routes/files/images.js
Normal file
58
api/server/routes/files/images.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
const { z } = require('zod');
|
||||
const fs = require('fs').promises;
|
||||
const express = require('express');
|
||||
const upload = require('./multer');
|
||||
const { localStrategy } = require('~/server/services/Files');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', upload.single('file'), async (req, res) => {
|
||||
const file = req.file;
|
||||
const metadata = req.body;
|
||||
// TODO: add file size/type validation
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
|
||||
try {
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
if (!metadata.file_id) {
|
||||
throw new Error('No file_id provided');
|
||||
}
|
||||
|
||||
if (!metadata.width) {
|
||||
throw new Error('No width provided');
|
||||
}
|
||||
|
||||
if (!metadata.height) {
|
||||
throw new Error('No height provided');
|
||||
}
|
||||
/* parse to validate api call */
|
||||
uuidSchema.parse(metadata.file_id);
|
||||
metadata.temp_file_id = metadata.file_id;
|
||||
metadata.file_id = req.file_id;
|
||||
await localStrategy({ res, file, metadata });
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error);
|
||||
try {
|
||||
await fs.unlink(file.path);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
res.status(500).json({ message: 'Error processing file' });
|
||||
}
|
||||
|
||||
// do this if strategy is not local
|
||||
// finally {
|
||||
// try {
|
||||
// // await fs.unlink(file.path);
|
||||
// } catch (error) {
|
||||
// console.error('Error deleting file:', error);
|
||||
|
||||
// }
|
||||
// }
|
||||
});
|
||||
|
||||
module.exports = router;
|
22
api/server/routes/files/index.js
Normal file
22
api/server/routes/files/index.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
uaParser,
|
||||
checkBan,
|
||||
requireJwtAuth,
|
||||
// concurrentLimiter,
|
||||
// messageIpLimiter,
|
||||
// messageUserLimiter,
|
||||
} = require('../../middleware');
|
||||
|
||||
const files = require('./files');
|
||||
const images = require('./images');
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
|
||||
router.use('/', files);
|
||||
router.use('/images', images);
|
||||
|
||||
module.exports = router;
|
41
api/server/routes/files/multer.js
Normal file
41
api/server/routes/files/multer.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
const sizeLimit = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const outputPath = path.join(req.app.locals.config.imageOutput, 'temp');
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
}
|
||||
cb(null, outputPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
req.file_id = crypto.randomUUID();
|
||||
const fileExt = path.extname(file.originalname);
|
||||
cb(null, `img-${req.file_id}${fileExt}`);
|
||||
},
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
if (!supportedTypes.includes(file.mimetype)) {
|
||||
return cb(
|
||||
new Error('Unsupported file type. Only JPEG, JPG, PNG, and WEBP files are allowed.'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > sizeLimit) {
|
||||
return cb(new Error(`File size exceeds ${sizeLimit / 1024 / 1024} MB.`), false);
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
const upload = multer({ storage, fileFilter });
|
||||
|
||||
module.exports = upload;
|
|
@ -16,6 +16,7 @@ const plugins = require('./plugins');
|
|||
const user = require('./user');
|
||||
const config = require('./config');
|
||||
const assistants = require('./assistants');
|
||||
const files = require('./files');
|
||||
|
||||
module.exports = {
|
||||
search,
|
||||
|
@ -36,4 +37,5 @@ module.exports = {
|
|||
plugins,
|
||||
config,
|
||||
assistants,
|
||||
files,
|
||||
};
|
||||
|
|
|
@ -1,21 +1,5 @@
|
|||
const RunManager = require('./Runs/RunMananger');
|
||||
|
||||
/**
|
||||
* @typedef {import('openai').OpenAI} OpenAI
|
||||
* @typedef {import('openai').OpenAI.Beta.Threads.ThreadMessage} ThreadMessage
|
||||
* @typedef {import('openai').OpenAI.Beta.Threads.RequiredActionFunctionToolCall} RequiredActionFunctionToolCall
|
||||
* @typedef {import('./Runs/RunManager').RunManager} RunManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Thread
|
||||
* @property {string} id - The identifier of the thread.
|
||||
* @property {string} object - The object type, always 'thread'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the thread was created.
|
||||
* @property {Object} [metadata] - Optional metadata associated with the thread.
|
||||
* @property {Message[]} [messages] - An array of messages associated with the thread.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Message
|
||||
* @property {string} id - The identifier of the message.
|
||||
|
@ -247,27 +231,6 @@ async function waitForRun({ openai, run_id, thread_id, runManager, pollIntervalM
|
|||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} AgentAction
|
||||
* @property {string} tool - The name of the tool used.
|
||||
* @property {string} toolInput - The input provided to the tool.
|
||||
* @property {string} log - A log or message associated with the action.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AgentFinish
|
||||
* @property {Record<string, any>} returnValues - The return values of the agent's execution.
|
||||
* @property {string} log - A log or message associated with the finish.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AgentFinish & { run_id: string; thread_id: string; }} OpenAIAssistantFinish
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AgentAction & { toolCallId: string; run_id: string; thread_id: string; }} OpenAIAssistantAction
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieves the response from an OpenAI run.
|
||||
*
|
||||
|
|
17
api/server/services/Files/images/convert.js
Normal file
17
api/server/services/Files/images/convert.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs').promises;
|
||||
const { resizeImage } = require('./resize');
|
||||
|
||||
async function convertToWebP(inputFilePath, resolution = 'high') {
|
||||
const { buffer: resizedBuffer, width, height } = await resizeImage(inputFilePath, resolution);
|
||||
const outputFilePath = inputFilePath.replace(/\.[^/.]+$/, '') + '.webp';
|
||||
const data = await sharp(resizedBuffer).toFormat('webp').toBuffer();
|
||||
await fs.writeFile(outputFilePath, data);
|
||||
const bytes = Buffer.byteLength(data);
|
||||
const filepath = path.posix.join('/', 'images', 'temp', path.basename(outputFilePath));
|
||||
await fs.unlink(inputFilePath);
|
||||
return { filepath, bytes, width, height };
|
||||
}
|
||||
|
||||
module.exports = { convertToWebP };
|
80
api/server/services/Files/images/encode.js
Normal file
80
api/server/services/Files/images/encode.js
Normal file
|
@ -0,0 +1,80 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { updateFile } = require('~/models');
|
||||
|
||||
function encodeImage(imagePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(imagePath, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(data.toString('base64'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function encodeAndMove(req, file) {
|
||||
const { publicPath, imageOutput } = req.app.locals.config;
|
||||
const userPath = path.join(imageOutput, req.user.id);
|
||||
|
||||
if (!fs.existsSync(userPath)) {
|
||||
fs.mkdirSync(userPath, { recursive: true });
|
||||
}
|
||||
const filepath = path.join(publicPath, file.filepath);
|
||||
|
||||
if (!filepath.includes('temp')) {
|
||||
const base64 = await encodeImage(filepath);
|
||||
return [file, base64];
|
||||
}
|
||||
|
||||
const newPath = path.join(userPath, path.basename(file.filepath));
|
||||
await fs.promises.rename(filepath, newPath);
|
||||
const newFilePath = path.posix.join('/', 'images', req.user.id, path.basename(file.filepath));
|
||||
const promises = [];
|
||||
promises.push(updateFile({ file_id: file.file_id, filepath: newFilePath }));
|
||||
promises.push(encodeImage(newPath));
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
|
||||
async function encodeAndFormat(req, files) {
|
||||
const promises = [];
|
||||
for (let file of files) {
|
||||
promises.push(encodeAndMove(req, file));
|
||||
}
|
||||
|
||||
// TODO: make detail configurable, as of now resizing is done
|
||||
// to prefer "high" but "low" may be used if the image is small enough
|
||||
const detail = req.body.detail ?? 'auto';
|
||||
const encodedImages = await Promise.all(promises);
|
||||
|
||||
const result = {
|
||||
files: [],
|
||||
image_urls: [],
|
||||
};
|
||||
|
||||
for (const [file, base64] of encodedImages) {
|
||||
result.image_urls.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:image/webp;base64,${base64}`,
|
||||
detail,
|
||||
},
|
||||
});
|
||||
|
||||
result.files.push({
|
||||
file_id: file.file_id,
|
||||
filepath: file.filepath,
|
||||
filename: file.filename,
|
||||
type: file.type,
|
||||
height: file.height,
|
||||
width: file.width,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encodeImage,
|
||||
encodeAndFormat,
|
||||
};
|
11
api/server/services/Files/images/index.js
Normal file
11
api/server/services/Files/images/index.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
const convert = require('./convert');
|
||||
const encode = require('./encode');
|
||||
const resize = require('./resize');
|
||||
const validate = require('./validate');
|
||||
|
||||
module.exports = {
|
||||
...convert,
|
||||
...encode,
|
||||
...resize,
|
||||
...validate,
|
||||
};
|
52
api/server/services/Files/images/resize.js
Normal file
52
api/server/services/Files/images/resize.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
const sharp = require('sharp');
|
||||
|
||||
async function resizeImage(inputFilePath, resolution) {
|
||||
const maxLowRes = 512;
|
||||
const maxShortSideHighRes = 768;
|
||||
const maxLongSideHighRes = 2000;
|
||||
|
||||
let newWidth, newHeight;
|
||||
let resizeOptions = { fit: 'inside', withoutEnlargement: true };
|
||||
|
||||
if (resolution === 'low') {
|
||||
resizeOptions.width = maxLowRes;
|
||||
resizeOptions.height = maxLowRes;
|
||||
} else if (resolution === 'high') {
|
||||
const metadata = await sharp(inputFilePath).metadata();
|
||||
const isWidthShorter = metadata.width < metadata.height;
|
||||
|
||||
if (isWidthShorter) {
|
||||
// Width is the shorter side
|
||||
newWidth = Math.min(metadata.width, maxShortSideHighRes);
|
||||
// Calculate new height to maintain aspect ratio
|
||||
newHeight = Math.round((metadata.height / metadata.width) * newWidth);
|
||||
// Ensure the long side does not exceed the maximum allowed
|
||||
if (newHeight > maxLongSideHighRes) {
|
||||
newHeight = maxLongSideHighRes;
|
||||
newWidth = Math.round((metadata.width / metadata.height) * newHeight);
|
||||
}
|
||||
} else {
|
||||
// Height is the shorter side
|
||||
newHeight = Math.min(metadata.height, maxShortSideHighRes);
|
||||
// Calculate new width to maintain aspect ratio
|
||||
newWidth = Math.round((metadata.width / metadata.height) * newHeight);
|
||||
// Ensure the long side does not exceed the maximum allowed
|
||||
if (newWidth > maxLongSideHighRes) {
|
||||
newWidth = maxLongSideHighRes;
|
||||
newHeight = Math.round((metadata.height / metadata.width) * newWidth);
|
||||
}
|
||||
}
|
||||
|
||||
resizeOptions.width = newWidth;
|
||||
resizeOptions.height = newHeight;
|
||||
} else {
|
||||
throw new Error('Invalid resolution parameter');
|
||||
}
|
||||
|
||||
const resizedBuffer = await sharp(inputFilePath).resize(resizeOptions).toBuffer();
|
||||
|
||||
const resizedMetadata = await sharp(resizedBuffer).metadata();
|
||||
return { buffer: resizedBuffer, width: resizedMetadata.width, height: resizedMetadata.height };
|
||||
}
|
||||
|
||||
module.exports = { resizeImage };
|
13
api/server/services/Files/images/validate.js
Normal file
13
api/server/services/Files/images/validate.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
const { visionModels } = require('~/server/routes/endpoints/schemas');
|
||||
|
||||
function validateVisionModel(model) {
|
||||
if (!model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return visionModels.some((visionModel) => model.includes(visionModel));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateVisionModel,
|
||||
};
|
9
api/server/services/Files/index.js
Normal file
9
api/server/services/Files/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
const localStrategy = require('./localStrategy');
|
||||
const process = require('./process');
|
||||
const save = require('./save');
|
||||
|
||||
module.exports = {
|
||||
...save,
|
||||
...process,
|
||||
localStrategy,
|
||||
};
|
34
api/server/services/Files/localStrategy.js
Normal file
34
api/server/services/Files/localStrategy.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
const { createFile } = require('~/models');
|
||||
const { convertToWebP } = require('./images/convert');
|
||||
|
||||
/**
|
||||
* Applies the local strategy for image uploads.
|
||||
* Saves file metadata to the database with an expiry TTL.
|
||||
* Files must be deleted from the server filesystem manually.
|
||||
*
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {Express.Response} params.res - The Express response object.
|
||||
* @param {Express.Multer.File} params.file - The uploaded file.
|
||||
* @param {ImageMetadata} params.metadata - Additional metadata for the file.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const localStrategy = async ({ res, file, metadata }) => {
|
||||
const { file_id, temp_file_id } = metadata;
|
||||
const { filepath, bytes, width, height } = await convertToWebP(file.path);
|
||||
const result = await createFile(
|
||||
{
|
||||
file_id,
|
||||
temp_file_id,
|
||||
bytes,
|
||||
filepath,
|
||||
filename: file.originalname,
|
||||
type: 'image/webp',
|
||||
width,
|
||||
height,
|
||||
},
|
||||
true,
|
||||
);
|
||||
res.status(200).json({ message: 'File uploaded and processed successfully', ...result });
|
||||
};
|
||||
|
||||
module.exports = localStrategy;
|
29
api/server/services/Files/process.js
Normal file
29
api/server/services/Files/process.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
const { updateFileUsage } = require('~/models');
|
||||
|
||||
// const mapImageUrls = (files, detail) => {
|
||||
// return files
|
||||
// .filter((file) => file.type.includes('image'))
|
||||
// .map((file) => ({
|
||||
// type: 'image_url',
|
||||
// image_url: {
|
||||
// /* Temporarily set to path to encode later */
|
||||
// url: file.filepath,
|
||||
// detail,
|
||||
// },
|
||||
// }));
|
||||
// };
|
||||
|
||||
const processFiles = async (files) => {
|
||||
const promises = [];
|
||||
for (let file of files) {
|
||||
const { file_id } = file;
|
||||
promises.push(updateFileUsage({ file_id }));
|
||||
}
|
||||
|
||||
// TODO: calculate token cost when image is first uploaded
|
||||
return await Promise.all(promises);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
processFiles,
|
||||
};
|
47
api/server/services/Files/save.js
Normal file
47
api/server/services/Files/save.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Saves a file to a specified output path with a new filename.
|
||||
*
|
||||
* @param {Express.Multer.File} file - The file object to be saved. Should contain properties like 'originalname' and 'path'.
|
||||
* @param {string} outputPath - The path where the file should be saved.
|
||||
* @param {string} outputFilename - The new filename for the saved file (without extension).
|
||||
* @returns {Promise<string>} The full path of the saved file.
|
||||
* @throws Will throw an error if the file saving process fails.
|
||||
*/
|
||||
async function saveFile(file, outputPath, outputFilename) {
|
||||
try {
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
const fileExtension = path.extname(file.originalname);
|
||||
const filenameWithExt = outputFilename + fileExtension;
|
||||
const outputFilePath = path.join(outputPath, filenameWithExt);
|
||||
fs.copyFileSync(file.path, outputFilePath);
|
||||
fs.unlinkSync(file.path);
|
||||
|
||||
return outputFilePath;
|
||||
} catch (error) {
|
||||
console.error('Error while saving the file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an uploaded image file to a specified directory based on the user's ID and a filename.
|
||||
*
|
||||
* @param {Express.Request} req - The Express request object, containing the user's information and app configuration.
|
||||
* @param {Express.Multer.File} file - The uploaded file object.
|
||||
* @param {string} filename - The new filename to assign to the saved image (without extension).
|
||||
* @returns {Promise<void>}
|
||||
* @throws Will throw an error if the image saving process fails.
|
||||
*/
|
||||
const saveLocalImage = async (req, file, filename) => {
|
||||
const imagePath = req.app.locals.config.imageOutput;
|
||||
const outputPath = path.join(imagePath, req.user.id ?? '');
|
||||
await saveFile(file, outputPath, filename);
|
||||
};
|
||||
|
||||
module.exports = { saveFile, saveLocalImage };
|
241
api/typedefs.js
Normal file
241
api/typedefs.js
Normal file
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* @namespace typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports OpenAI
|
||||
* @typedef {import('openai').OpenAI} OpenAI
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports Assistant
|
||||
* @typedef {import('librechat-data-provider').Assistant} Assistant
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports OpenAIFile
|
||||
* @typedef {import('librechat-data-provider').File} OpenAIFile
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ImageMetadata
|
||||
* @typedef {Object} ImageMetadata
|
||||
* @property {string} file_id - The identifier of the file.
|
||||
* @property {string} [temp_file_id] - The temporary identifier of the file.
|
||||
* @property {number} width - The width of the image.
|
||||
* @property {number} height - The height of the image.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports MongoFile
|
||||
* @typedef {import('~/models/schema/fileSchema.js').MongoFile} MongoFile
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AssistantCreateParams
|
||||
* @typedef {import('librechat-data-provider').AssistantCreateParams} AssistantCreateParams
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AssistantUpdateParams
|
||||
* @typedef {import('librechat-data-provider').AssistantUpdateParams} AssistantUpdateParams
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AssistantListParams
|
||||
* @typedef {import('librechat-data-provider').AssistantListParams} AssistantListParams
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AssistantListResponse
|
||||
* @typedef {import('librechat-data-provider').AssistantListResponse} AssistantListResponse
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ThreadMessage
|
||||
* @typedef {import('openai').OpenAI.Beta.Threads.ThreadMessage} ThreadMessage
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports RequiredActionFunctionToolCall
|
||||
* @typedef {import('openai').OpenAI.Beta.Threads.RequiredActionFunctionToolCall} RequiredActionFunctionToolCall
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports RunManager
|
||||
* @typedef {import('./server/services/Runs/RunMananger.js').RunManager} RunManager
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports Thread
|
||||
* @typedef {Object} Thread
|
||||
* @property {string} id - The identifier of the thread.
|
||||
* @property {string} object - The object type, always 'thread'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the thread was created.
|
||||
* @property {Object} [metadata] - Optional metadata associated with the thread.
|
||||
* @property {Message[]} [messages] - An array of messages associated with the thread.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports Message
|
||||
* @typedef {Object} Message
|
||||
* @property {string} id - The identifier of the message.
|
||||
* @property {string} object - The object type, always 'thread.message'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the message was created.
|
||||
* @property {string} thread_id - The thread ID that this message belongs to.
|
||||
* @property {string} role - The entity that produced the message. One of 'user' or 'assistant'.
|
||||
* @property {Object[]} content - The content of the message in an array of text and/or images.
|
||||
* @property {string} content[].type - The type of content, either 'text' or 'image_file'.
|
||||
* @property {Object} [content[].text] - The text content, present if type is 'text'.
|
||||
* @property {string} content[].text.value - The data that makes up the text.
|
||||
* @property {Object[]} [content[].text.annotations] - Annotations for the text content.
|
||||
* @property {Object} [content[].image_file] - The image file content, present if type is 'image_file'.
|
||||
* @property {string} content[].image_file.file_id - The File ID of the image in the message content.
|
||||
* @property {string[]} [file_ids] - Optional list of File IDs for the message.
|
||||
* @property {string|null} [assistant_id] - If applicable, the ID of the assistant that authored this message.
|
||||
* @property {string|null} [run_id] - If applicable, the ID of the run associated with the authoring of this message.
|
||||
* @property {Object} [metadata] - Optional metadata for the message, a map of key-value pairs.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports FunctionTool
|
||||
* @typedef {Object} FunctionTool
|
||||
* @property {string} type - The type of tool, 'function'.
|
||||
* @property {Object} function - The function definition.
|
||||
* @property {string} function.description - A description of what the function does.
|
||||
* @property {string} function.name - The name of the function to be called.
|
||||
* @property {Object} function.parameters - The parameters the function accepts, described as a JSON Schema object.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports Tool
|
||||
* @typedef {Object} Tool
|
||||
* @property {string} type - The type of tool, can be 'code_interpreter', 'retrieval', or 'function'.
|
||||
* @property {FunctionTool} [function] - The function tool, present if type is 'function'.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports Run
|
||||
* @typedef {Object} Run
|
||||
* @property {string} id - The identifier of the run.
|
||||
* @property {string} object - The object type, always 'thread.run'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the run was created.
|
||||
* @property {string} thread_id - The ID of the thread that was executed on as a part of this run.
|
||||
* @property {string} assistant_id - The ID of the assistant used for execution of this run.
|
||||
* @property {string} status - The status of the run (e.g., 'queued', 'completed').
|
||||
* @property {Object} [required_action] - Details on the action required to continue the run.
|
||||
* @property {string} required_action.type - The type of required action, always 'submit_tool_outputs'.
|
||||
* @property {Object} required_action.submit_tool_outputs - Details on the tool outputs needed for the run to continue.
|
||||
* @property {Object[]} required_action.submit_tool_outputs.tool_calls - A list of the relevant tool calls.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].id - The ID of the tool call.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].type - The type of tool call the output is required for, always 'function'.
|
||||
* @property {Object} required_action.submit_tool_outputs.tool_calls[].function - The function definition.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].function.name - The name of the function.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].function.arguments - The arguments that the model expects you to pass to the function.
|
||||
* @property {Object} [last_error] - The last error associated with this run.
|
||||
* @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
|
||||
* @property {string} last_error.message - A human-readable description of the error.
|
||||
* @property {number} [expires_at] - The Unix timestamp (in seconds) for when the run will expire.
|
||||
* @property {number} [started_at] - The Unix timestamp (in seconds) for when the run was started.
|
||||
* @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run was cancelled.
|
||||
* @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run failed.
|
||||
* @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run was completed.
|
||||
* @property {string} [model] - The model that the assistant used for this run.
|
||||
* @property {string} [instructions] - The instructions that the assistant used for this run.
|
||||
* @property {Tool[]} [tools] - The list of tools used for this run.
|
||||
* @property {string[]} [file_ids] - The list of File IDs used for this run.
|
||||
* @property {Object} [metadata] - Metadata associated with this run.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports RunStep
|
||||
* @typedef {Object} RunStep
|
||||
* @property {string} id - The identifier of the run step.
|
||||
* @property {string} object - The object type, always 'thread.run.step'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the run step was created.
|
||||
* @property {string} assistant_id - The ID of the assistant associated with the run step.
|
||||
* @property {string} thread_id - The ID of the thread that was run.
|
||||
* @property {string} run_id - The ID of the run that this run step is a part of.
|
||||
* @property {string} type - The type of run step, either 'message_creation' or 'tool_calls'.
|
||||
* @property {string} status - The status of the run step, can be 'in_progress', 'cancelled', 'failed', 'completed', or 'expired'.
|
||||
* @property {Object} step_details - The details of the run step.
|
||||
* @property {Object} [last_error] - The last error associated with this run step.
|
||||
* @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
|
||||
* @property {string} last_error.message - A human-readable description of the error.
|
||||
* @property {number} [expired_at] - The Unix timestamp (in seconds) for when the run step expired.
|
||||
* @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run step was cancelled.
|
||||
* @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run step failed.
|
||||
* @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run step completed.
|
||||
* @property {Object} [metadata] - Metadata associated with this run step, a map of up to 16 key-value pairs.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports StepMessage
|
||||
* @typedef {Object} StepMessage
|
||||
* @property {Message} message - The complete message object created by the step.
|
||||
* @property {string} id - The identifier of the run step.
|
||||
* @property {string} object - The object type, always 'thread.run.step'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the run step was created.
|
||||
* @property {string} assistant_id - The ID of the assistant associated with the run step.
|
||||
* @property {string} thread_id - The ID of the thread that was run.
|
||||
* @property {string} run_id - The ID of the run that this run step is a part of.
|
||||
* @property {string} type - The type of run step, either 'message_creation' or 'tool_calls'.
|
||||
* @property {string} status - The status of the run step, can be 'in_progress', 'cancelled', 'failed', 'completed', or 'expired'.
|
||||
* @property {Object} step_details - The details of the run step.
|
||||
* @property {Object} [last_error] - The last error associated with this run step.
|
||||
* @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
|
||||
* @property {string} last_error.message - A human-readable description of the error.
|
||||
* @property {number} [expired_at] - The Unix timestamp (in seconds) for when the run step expired.
|
||||
* @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run step was cancelled.
|
||||
* @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run step failed.
|
||||
* @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run step completed.
|
||||
* @property {Object} [metadata] - Metadata associated with this run step, a map of up to 16 key-value pairs.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AgentAction
|
||||
* @typedef {Object} AgentAction
|
||||
* @property {string} tool - The name of the tool used.
|
||||
* @property {string} toolInput - The input provided to the tool.
|
||||
* @property {string} log - A log or message associated with the action.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AgentFinish
|
||||
* @typedef {Object} AgentFinish
|
||||
* @property {Record<string, any>} returnValues - The return values of the agent's execution.
|
||||
* @property {string} log - A log or message associated with the finish.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports OpenAIAssistantFinish
|
||||
* @typedef {AgentFinish & { run_id: string; thread_id: string; }} OpenAIAssistantFinish
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports OpenAIAssistantAction
|
||||
* @typedef {AgentAction & { toolCallId: string; run_id: string; thread_id: string; }} OpenAIAssistantAction
|
||||
* @memberof typedefs
|
||||
*/
|
|
@ -52,6 +52,7 @@
|
|||
"export-from-json": "^1.7.2",
|
||||
"filenamify": "^6.0.0",
|
||||
"html-to-image": "^1.11.11",
|
||||
"image-blob-reduce": "^4.1.0",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.220.0",
|
||||
|
@ -61,6 +62,7 @@
|
|||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^8.0.6",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type { TShowToast } from '~/common';
|
||||
import { useToast } from '~/hooks';
|
||||
import useToast from '~/hooks/useToast';
|
||||
|
||||
type ToastContextType = {
|
||||
showToast: ({ message, severity, showIcon }: TShowToast) => void;
|
||||
showToast: ({ message, severity, showIcon, duration }: TShowToast) => void;
|
||||
};
|
||||
|
||||
export const ToastContext = createContext<ToastContextType>({
|
||||
|
|
|
@ -6,7 +6,6 @@ import type {
|
|||
TLoginUser,
|
||||
TUser,
|
||||
} from 'librechat-data-provider';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
export type TSetOption = (param: number | string) => (newValue: number | string | boolean) => void;
|
||||
export type TSetExample = (
|
||||
|
@ -15,22 +14,6 @@ export type TSetExample = (
|
|||
newValue: number | string | boolean | null,
|
||||
) => void;
|
||||
|
||||
export const alternateName = {
|
||||
[EModelEndpoint.openAI]: 'OpenAI',
|
||||
[EModelEndpoint.assistant]: 'Assistants',
|
||||
[EModelEndpoint.azureOpenAI]: 'Azure OpenAI',
|
||||
[EModelEndpoint.bingAI]: 'Bing',
|
||||
[EModelEndpoint.chatGPTBrowser]: 'ChatGPT',
|
||||
[EModelEndpoint.gptPlugins]: 'Plugins',
|
||||
[EModelEndpoint.google]: 'PaLM',
|
||||
[EModelEndpoint.anthropic]: 'Anthropic',
|
||||
};
|
||||
|
||||
export const supportsFiles = {
|
||||
[EModelEndpoint.openAI]: true,
|
||||
[EModelEndpoint.assistant]: true,
|
||||
};
|
||||
|
||||
export enum ESide {
|
||||
Top = 'top',
|
||||
Right = 'right',
|
||||
|
@ -49,6 +32,7 @@ export type TShowToast = {
|
|||
message: string;
|
||||
severity?: NotificationSeverity;
|
||||
showIcon?: boolean;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type TBaseSettingsProps = {
|
||||
|
@ -233,8 +217,14 @@ export type TOptionSettings = {
|
|||
|
||||
export interface ExtendedFile {
|
||||
file: File;
|
||||
file_id: string;
|
||||
temp_file_id?: string;
|
||||
type?: string;
|
||||
filepath?: string;
|
||||
filename?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size: number;
|
||||
preview: string;
|
||||
progress: number;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ function Login() {
|
|||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/chat/new', { replace: true });
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ function Registration() {
|
|||
const onRegisterUserFormSubmit = (data: TRegisterUser) => {
|
||||
registerUser.mutate(data, {
|
||||
onSuccess: () => {
|
||||
navigate('/chat/new');
|
||||
navigate('/c/new');
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(true);
|
||||
|
|
|
@ -129,7 +129,7 @@ test('renders registration form', () => {
|
|||
// console.log(history);
|
||||
// waitFor(() => {
|
||||
// // expect(mutate).toHaveBeenCalled();
|
||||
// expect(history.location.pathname).toBe('/chat/new');
|
||||
// expect(history.location.pathname).toBe('/c/new');
|
||||
// });
|
||||
// });
|
||||
|
||||
|
|
|
@ -2,12 +2,12 @@ import { memo } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetMessagesByConvoId } from 'librechat-data-provider';
|
||||
import { useChatHelpers, useDragHelpers, useSSE } from '~/hooks';
|
||||
import { useChatHelpers, useSSE } from '~/hooks';
|
||||
// import GenerationButtons from './Input/GenerationButtons';
|
||||
import DragDropOverlay from './Input/Files/DragDropOverlay';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
// import OptionsBar from './Input/OptionsBar';
|
||||
import { ChatContext } from '~/Providers';
|
||||
import Presentation from './Presentation';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import { Spinner } from '~/components';
|
||||
import { buildTree } from '~/utils';
|
||||
|
@ -16,15 +16,7 @@ import Header from './Header';
|
|||
import Footer from './Footer';
|
||||
import store from '~/store';
|
||||
|
||||
function ChatView({
|
||||
// messagesTree,
|
||||
// isLoading,
|
||||
index = 0,
|
||||
}: {
|
||||
// messagesTree?: TMessage[] | null;
|
||||
// isLoading: boolean;
|
||||
index?: number;
|
||||
}) {
|
||||
function ChatView({ index = 0 }: { index?: number }) {
|
||||
const { conversationId } = useParams();
|
||||
const submissionAtIndex = useRecoilValue(store.submissionByIndex(0));
|
||||
useSSE(submissionAtIndex);
|
||||
|
@ -35,36 +27,28 @@ function ChatView({
|
|||
return dataTree?.length === 0 ? null : dataTree ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const chatHelpers = useChatHelpers(index, conversationId);
|
||||
const { isOver, canDrop, drop } = useDragHelpers(chatHelpers.setFiles);
|
||||
const isActive = canDrop && isOver;
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={chatHelpers}>
|
||||
<div
|
||||
ref={drop}
|
||||
className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{isLoading && conversationId !== 'new' ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="dark:text-white" />
|
||||
</div>
|
||||
) : messagesTree && messagesTree.length !== 0 ? (
|
||||
<MessagesView messagesTree={messagesTree} Header={<Header />} />
|
||||
) : (
|
||||
<Landing Header={<Header />} />
|
||||
)}
|
||||
{/* <OptionsBar messagesTree={messagesTree} /> */}
|
||||
{/* <GenerationButtons endpoint={chatHelpers.conversation.endpoint ?? ''} /> */}
|
||||
<div className="gizmo:border-t-0 gizmo:pl-0 gizmo:md:pl-0 w-full border-t pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-2 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
<Footer />
|
||||
</div>
|
||||
{isActive && <DragDropOverlay />}
|
||||
<Presentation>
|
||||
{isLoading && conversationId !== 'new' ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="opacity-0" />
|
||||
</div>
|
||||
) : messagesTree && messagesTree.length !== 0 ? (
|
||||
<MessagesView messagesTree={messagesTree} Header={<Header />} />
|
||||
) : (
|
||||
<Landing Header={<Header />} />
|
||||
)}
|
||||
{/* <OptionsBar messagesTree={messagesTree} /> */}
|
||||
{/* <GenerationButtons endpoint={chatHelpers.conversation.endpoint ?? ''} /> */}
|
||||
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</Presentation>
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,28 @@
|
|||
import { useGetStartupConfig } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function Footer() {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="relative px-2 py-2 text-center text-xs text-gray-600 dark:text-gray-300 md:px-[60px]">
|
||||
<span>ChatGPT can make mistakes. Consider checking important information.</span>
|
||||
<span>
|
||||
{typeof config?.customFooter === 'string' ? (
|
||||
config.customFooter
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/danny-avila/LibreChat"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{config?.appTitle || 'LibreChat'} v0.6.1
|
||||
</a>
|
||||
{' - '} {localize('com_ui_new_footer')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,8 +10,16 @@ import store from '~/store';
|
|||
|
||||
export default function ChatForm({ index = 0 }) {
|
||||
const [text, setText] = useRecoilState(store.textByIndex(index));
|
||||
const { ask, files, setFiles, conversation, isSubmitting, handleStopGenerating } =
|
||||
useChatContext();
|
||||
const {
|
||||
ask,
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
handleStopGenerating,
|
||||
filesLoading,
|
||||
setFilesLoading,
|
||||
} = useChatContext();
|
||||
|
||||
const submitMessage = () => {
|
||||
ask({ text });
|
||||
|
@ -29,7 +37,7 @@ export default function ChatForm({ index = 0 }) {
|
|||
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="[&:has(textarea:focus)]:border-token-border-xheavy border-token-border-heavy shadow-xs dark:shadow-xs relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-black/10 bg-white shadow-[0_0_0_2px_rgba(255,255,255,0.95)] dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:shadow-[0_0_0_2px_rgba(52,53,65,0.95)] [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<Images files={files} setFiles={setFiles} />
|
||||
<Images files={files} setFiles={setFiles} setFilesLoading={setFilesLoading} />
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
|
||||
|
@ -38,7 +46,11 @@ export default function ChatForm({ index = 0 }) {
|
|||
endpoint={conversation?.endpoint}
|
||||
/>
|
||||
<AttachFile endpoint={conversation?.endpoint ?? ''} />
|
||||
{isSubmitting ? <StopButton stop={handleStopGenerating} /> : <SendButton text={text} />}
|
||||
{isSubmitting ? (
|
||||
<StopButton stop={handleStopGenerating} />
|
||||
) : (
|
||||
<SendButton text={text} disabled={filesLoading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, supportsFiles } from 'librechat-data-provider';
|
||||
import { AttachmentIcon } from '~/components/svg';
|
||||
import { FileUpload } from '~/components/ui';
|
||||
import { useFileHandling } from '~/hooks';
|
||||
import { supportsFiles } from '~/common';
|
||||
|
||||
export default function AttachFile({ endpoint }: { endpoint: EModelEndpoint | '' }) {
|
||||
const { handleFileChange } = useFileHandling();
|
||||
|
@ -11,9 +10,14 @@ export default function AttachFile({ endpoint }: { endpoint: EModelEndpoint | ''
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-1 left-0 md:left-1">
|
||||
<div className="absolute bottom-2 left-2 md:bottom-3 md:left-4">
|
||||
<FileUpload handleFileChange={handleFileChange} className="flex">
|
||||
<button className="btn relative p-0 text-black dark:text-white" aria-label="Attach files">
|
||||
<button
|
||||
type="button"
|
||||
className="btn relative p-0 text-black dark:text-white"
|
||||
aria-label="Attach files"
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<AttachmentIcon />
|
||||
</div>
|
||||
|
|
|
@ -87,6 +87,7 @@ const Image = ({
|
|||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-white bg-gray-500 p-0.5 text-white transition-colors hover:bg-black hover:opacity-100 group-hover:opacity-100 md:opacity-0"
|
||||
onClick={onDelete}
|
||||
>
|
||||
|
|
|
@ -1,25 +1,100 @@
|
|||
import Image from './Image';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { BatchFile } from 'librechat-data-provider';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import { ExtendedFile } from '~/common';
|
||||
import Image from './Image';
|
||||
|
||||
export default function Images({
|
||||
files,
|
||||
files: _files,
|
||||
setFiles,
|
||||
setFilesLoading,
|
||||
}: {
|
||||
files: ExtendedFile[];
|
||||
setFiles: React.Dispatch<React.SetStateAction<ExtendedFile[]>>;
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
|
||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_batch, setFileDeleteBatch] = useState<BatchFile[]>([]);
|
||||
const files = Array.from(_files.values());
|
||||
|
||||
useEffect(() => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.some((file) => file.progress < 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.every((file) => file.progress === 1)) {
|
||||
setFilesLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [files]);
|
||||
|
||||
const deleteFiles = useDeleteFilesMutation({
|
||||
onSuccess: () => {
|
||||
console.log('Files deleted');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('Error deleting files:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const executeBatchDelete = useCallback(
|
||||
(filesToDelete: BatchFile[]) => {
|
||||
console.log('Deleting files:', filesToDelete);
|
||||
deleteFiles.mutate({ files: filesToDelete });
|
||||
setFileDeleteBatch([]);
|
||||
},
|
||||
[deleteFiles],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedDelete = useCallback(debounce(executeBatchDelete, 1000), []);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup function for debouncedDelete when component unmounts or before re-render
|
||||
return () => debouncedDelete.cancel();
|
||||
}, [debouncedDelete]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deleteFile = (_file: ExtendedFile) => {
|
||||
const { file_id, progress, temp_file_id = '', filepath = '' } = _file;
|
||||
if (progress < 1) {
|
||||
return;
|
||||
}
|
||||
const file = {
|
||||
file_id,
|
||||
filepath,
|
||||
};
|
||||
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.delete(file_id);
|
||||
updatedFiles.delete(temp_file_id);
|
||||
return updatedFiles;
|
||||
});
|
||||
|
||||
setFileDeleteBatch((prevBatch) => {
|
||||
const newBatch = [...prevBatch, file];
|
||||
debouncedDelete(newBatch);
|
||||
return newBatch;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
|
||||
{files.map((file: ExtendedFile, index: number) => {
|
||||
const handleDelete = () => {
|
||||
setFiles((currentFiles) =>
|
||||
currentFiles.filter((_file) => file.preview !== _file.preview),
|
||||
);
|
||||
};
|
||||
const handleDelete = () => deleteFile(file);
|
||||
return (
|
||||
<Image key={index} url={file.preview} onDelete={handleDelete} progress={file.progress} />
|
||||
);
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import { SendIcon } from '~/components/svg';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function SendButton({ text }) {
|
||||
export default function SendButton({ text, disabled }) {
|
||||
return (
|
||||
<button
|
||||
disabled={!text}
|
||||
className="enabled:bg-brand-purple absolute bottom-2.5 right-1.5 rounded-lg rounded-md border border-black p-0.5 p-1 text-white transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white md:bottom-3 md:right-3 md:p-[2px]"
|
||||
disabled={!text || disabled}
|
||||
className={cn(
|
||||
'enabled:bg-brand-purple absolute rounded-lg rounded-md border border-black p-0.5 p-1 text-white transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white ',
|
||||
'bottom-1.5 right-1.5 md:bottom-2.5 md:right-3 md:p-[2px]',
|
||||
)}
|
||||
data-testid="send-button"
|
||||
type="submit"
|
||||
>
|
||||
|
|
|
@ -1,31 +1,27 @@
|
|||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { supportsFiles } from '~/common';
|
||||
import { useTextarea } from '~/hooks';
|
||||
import { supportsFiles } from 'librechat-data-provider';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useTextarea } from '~/hooks';
|
||||
|
||||
export default function Textarea({ value, onChange, setText, submitMessage, endpoint }) {
|
||||
const {
|
||||
inputRef,
|
||||
handleKeyDown,
|
||||
handlePaste,
|
||||
handleKeyUp,
|
||||
handleKeyDown,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
onHeightChange,
|
||||
placeholder,
|
||||
} = useTextarea({ setText, submitMessage });
|
||||
|
||||
const className = supportsFiles[endpoint]
|
||||
? // ? 'm-0 w-full resize-none border-0 bg-transparent py-3.5 pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent placeholder-black/50 dark:placeholder-white/50 pl-10 md:py-3.5 md:pr-12 md:pl-[55px]'
|
||||
// : 'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pr-12 gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 pl-3 md:pl-4';
|
||||
'm-0 w-full resize-none border-0 bg-transparent py-3.5 pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent placeholder-black/50 dark:placeholder-white/50 pl-10 md:py-3.5 md:pr-12 md:pl-[55px]'
|
||||
: 'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-3.5 md:pr-12 placeholder-black/50 dark:placeholder-white/50 pl-3 md:pl-4';
|
||||
|
||||
return (
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
|
@ -34,12 +30,15 @@ export default function Textarea({ value, onChange, setText, submitMessage, endp
|
|||
id="prompt-textarea"
|
||||
tabIndex={0}
|
||||
data-testid="text-input"
|
||||
// style={{ maxHeight: '200px', height: '52px', overflowY: 'hidden' }}
|
||||
style={{ height: 44, overflowY: 'hidden' }}
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
// className="m-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pr-12 gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 pl-12 gizmo:pl-10 md:pl-[46px] gizmo:md:pl-[55px]"
|
||||
// className="gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 gizmo:pl-10 gizmo:md:pl-[55px] m-0 h-auto max-h-52 w-full resize-none overflow-y-hidden border-0 bg-transparent py-[10px] pl-12 pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pl-[46px] md:pr-12"
|
||||
className={cn(className, removeFocusOutlines, 'max-h-52')}
|
||||
className={cn(
|
||||
supportsFiles[endpoint] ? ' pl-10 md:pl-[55px]' : 'pl-3 md:pl-4',
|
||||
'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 md:pr-12 ',
|
||||
removeFocusOutlines,
|
||||
'max-h-52',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,28 +1,25 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { icons } from './Menus/Endpoints/Icons';
|
||||
import { useChatContext } from '~/Providers';
|
||||
export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||
const { conversation } = useChatContext();
|
||||
let { endpoint } = conversation ?? {};
|
||||
if (
|
||||
endpoint === EModelEndpoint.assistant ||
|
||||
endpoint === EModelEndpoint.chatGPTBrowser ||
|
||||
endpoint === EModelEndpoint.azureOpenAI ||
|
||||
endpoint === EModelEndpoint.gptPlugins
|
||||
) {
|
||||
endpoint = EModelEndpoint.openAI;
|
||||
}
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="absolute left-0 right-0">{Header && Header}</div>
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="mb-3 h-[72px] w-[72px]">
|
||||
<div className="gizmo-shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
||||
<svg
|
||||
width="41"
|
||||
height="41"
|
||||
viewBox="0 0 41 41"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-2/3 w-2/3"
|
||||
role="img"
|
||||
>
|
||||
<text x="-9999" y="-9999">
|
||||
ChatGPT
|
||||
</text>
|
||||
<path
|
||||
d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
{icons[endpoint ?? 'unknown']({ size: 41, className: 'h-2/3 w-2/3' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5 text-2xl font-medium dark:text-white">How can I help you today?</div>
|
||||
|
|
|
@ -49,7 +49,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
<>
|
||||
<div
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
|
||||
className="group m-1.5 flex max-h-[40px] cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
onClick={() => onSelectEndpoint(endpoint)}
|
||||
|
@ -75,7 +75,9 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
className={cn(
|
||||
'invisible flex gap-x-1 group-hover:visible',
|
||||
selected ? 'visible' : '',
|
||||
expiryTime ? 'w-full rounded-lg p-2 hover:bg-gray-900' : '',
|
||||
expiryTime
|
||||
? 'w-full rounded-lg p-2 hover:bg-gray-200 dark:hover:bg-gray-900'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -108,7 +110,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
)}
|
||||
{(!userProvidesKey || expiryTime) && (
|
||||
<div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
|
||||
<div className="">New Chat</div>
|
||||
{!userProvidesKey && <div className="">New Chat</div>}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { FC } from 'react';
|
||||
import { EModelEndpoint, useGetEndpointsQuery } from 'librechat-data-provider';
|
||||
import { Close } from '@radix-ui/react-popover';
|
||||
import { EModelEndpoint, useGetEndpointsQuery, alternateName } from 'librechat-data-provider';
|
||||
import MenuSeparator from '../UI/MenuSeparator';
|
||||
import { alternateName } from '~/common';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
const EndpointItems: FC<{
|
||||
|
@ -20,18 +20,20 @@ const EndpointItems: FC<{
|
|||
}
|
||||
const userProvidesKey = endpointsConfig?.[endpoint]?.userProvide;
|
||||
return (
|
||||
<div key={`endpoint-${endpoint}`}>
|
||||
<MenuItem
|
||||
key={`endpoint-item-${endpoint}`}
|
||||
title={alternateName[endpoint] || endpoint}
|
||||
value={endpoint}
|
||||
selected={selected === endpoint}
|
||||
data-testid={`endpoint-item-${endpoint}`}
|
||||
userProvidesKey={!!userProvidesKey}
|
||||
// description="With DALL·E, browsing and analysis"
|
||||
/>
|
||||
{i !== endpoints.length - 1 && <MenuSeparator />}
|
||||
</div>
|
||||
<Close asChild key={`endpoint-${endpoint}`}>
|
||||
<div key={`endpoint-${endpoint}`}>
|
||||
<MenuItem
|
||||
key={`endpoint-item-${endpoint}`}
|
||||
title={alternateName[endpoint] || endpoint}
|
||||
value={endpoint}
|
||||
selected={selected === endpoint}
|
||||
data-testid={`endpoint-item-${endpoint}`}
|
||||
userProvidesKey={!!userProvidesKey}
|
||||
// description="With DALL·E, browsing and analysis"
|
||||
/>
|
||||
{i !== endpoints.length - 1 && <MenuSeparator />}
|
||||
</div>
|
||||
</Close>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { Content, Portal, Root } from '@radix-ui/react-popover';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery, alternateName } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import EndpointItems from './Endpoints/MenuItems';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import TitleButton from './UI/TitleButton';
|
||||
import { alternateName } from '~/common';
|
||||
import { mapEndpoints } from '~/utils';
|
||||
|
||||
const EndpointsMenu: FC = () => {
|
||||
|
|
|
@ -98,20 +98,20 @@ const PresetItems: FC<{
|
|||
className="m-0 h-full rounded-md p-2 px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDeletePreset(preset);
|
||||
onChangePreset(preset);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
className="m-0 h-full rounded-md p-2 px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChangePreset(preset);
|
||||
e.stopPropagation();
|
||||
onDeletePreset(preset);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
|
|
@ -10,13 +10,14 @@ import {
|
|||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
|
||||
import { useLocalize, useDefaultConvo, useNavigateToConvo } from '~/hooks';
|
||||
import { useChatContext, useToastContext } from '~/Providers';
|
||||
import { EditPresetDialog, PresetItems } from './Presets';
|
||||
import { cleanupPreset, cn } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
||||
const PresetsMenu: FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { conversation, newConversation, setPreset } = useChatContext();
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
|
@ -52,6 +53,12 @@ const PresetsMenu: FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize('com_endpoint_preset_selected'),
|
||||
showIcon: false,
|
||||
duration: 750,
|
||||
});
|
||||
|
||||
if (
|
||||
modularEndpoints.has(endpoint ?? '') &&
|
||||
modularEndpoints.has(newPreset?.endpoint ?? '') &&
|
||||
|
@ -95,7 +102,7 @@ const PresetsMenu: FC = () => {
|
|||
)}
|
||||
id="presets-button"
|
||||
data-testid="presets-button"
|
||||
title={localize('com_ui_presets')}
|
||||
title={localize('com_endpoint_examples')}
|
||||
>
|
||||
<BookCopy className="icon-sm" id="presets-button" />
|
||||
</button>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Container Component
|
||||
const Container = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="text-message peer flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto break-words peer-[.text-message]:mt-5">
|
||||
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto [.text-message+&]:mt-5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
42
client/src/components/Chat/Messages/Content/DialogImage.tsx
Normal file
42
client/src/components/Chat/Messages/Content/DialogImage.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
|
||||
export default function DialogImage({ src = '', width = 1920, height = 1080 }) {
|
||||
return (
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className="radix-state-open:animate-show fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/90 dark:bg-black/80"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute right-4 top-4 text-gray-50 transition hover:text-gray-200"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-5 w-5"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Content
|
||||
className="radix-state-open:animate-contentShow relative max-h-[85vh] max-w-[90vw] shadow-xl focus:outline-none"
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'auto', aspectRatio: height > width ? 1 / 1.75 : 1.75 / 1 }}
|
||||
>
|
||||
<img src={src} alt="Uploaded image" className="h-full w-full object-contain" />
|
||||
</Dialog.Content>
|
||||
</Dialog.Overlay>
|
||||
</Dialog.Portal>
|
||||
);
|
||||
}
|
85
client/src/components/Chat/Messages/Content/Image.tsx
Normal file
85
client/src/components/Chat/Messages/Content/Image.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import React, { useState, useEffect, useRef, memo } from 'react';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import DialogImage from './DialogImage';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Image = ({
|
||||
imagePath,
|
||||
altText,
|
||||
height,
|
||||
width,
|
||||
}: // n,
|
||||
// i,
|
||||
{
|
||||
imagePath: string;
|
||||
altText: string;
|
||||
height: number;
|
||||
width: number;
|
||||
// n: number;
|
||||
// i: number;
|
||||
}) => {
|
||||
const prevImagePathRef = useRef<string | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const handleImageLoad = () => setIsLoaded(true);
|
||||
const [minDisplayTimeElapsed, setMinDisplayTimeElapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isLoaded) {
|
||||
timer = setTimeout(() => setMinDisplayTimeElapsed(true), 150);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const prevImagePath = prevImagePathRef.current;
|
||||
if (prevImagePath && prevImagePath?.startsWith('blob:') && prevImagePath !== imagePath) {
|
||||
URL.revokeObjectURL(prevImagePath);
|
||||
}
|
||||
prevImagePathRef.current = imagePath;
|
||||
}, [imagePath]);
|
||||
// const makeSquare = n >= 3 && i < 2;
|
||||
|
||||
const placeholderHeight = height > width ? '900px' : '288px';
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<div className="">
|
||||
<div className="relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<LazyLoadImage
|
||||
// loading="lazy"
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
className={cn(
|
||||
'max-h-[900px] max-w-full opacity-100 transition-opacity duration-300',
|
||||
// n >= 3 && i < 2 ? 'aspect-square object-cover' : '',
|
||||
isLoaded && minDisplayTimeElapsed ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
style={{
|
||||
height: isLoaded && minDisplayTimeElapsed ? 'auto' : placeholderHeight,
|
||||
width,
|
||||
color: 'transparent',
|
||||
}}
|
||||
placeholder={
|
||||
<div
|
||||
style={{
|
||||
height: isLoaded && minDisplayTimeElapsed ? 'auto' : placeholderHeight,
|
||||
width,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<DialogImage src={imagePath} height={height} width={width} />
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Image);
|
|
@ -9,6 +9,7 @@ import EditMessage from './EditMessage';
|
|||
import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
import { cn } from '~/utils';
|
||||
import Image from './Image';
|
||||
|
||||
const ErrorMessage = ({ text }: TText) => {
|
||||
const { logout } = useAuthContext();
|
||||
|
@ -27,22 +28,39 @@ const ErrorMessage = ({ text }: TText) => {
|
|||
};
|
||||
|
||||
// Display Message Component
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => (
|
||||
<Container>
|
||||
<div
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
|
||||
)}
|
||||
>
|
||||
{!isCreatedByUser ? (
|
||||
<Markdown content={text} message={message} showCursor={showCursor} />
|
||||
) : (
|
||||
<>{text}</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const imageFiles = message?.files
|
||||
? message.files.filter((file) => file.type.startsWith('image/'))
|
||||
: null;
|
||||
return (
|
||||
<Container>
|
||||
{imageFiles &&
|
||||
imageFiles.map((file, i) => (
|
||||
<Image
|
||||
key={file.file_id}
|
||||
imagePath={file.preview ?? file.filepath ?? ''}
|
||||
height={file.height ?? 1920}
|
||||
width={file.width ?? 1080}
|
||||
altText={file.filename ?? 'Uploaded Image'}
|
||||
// n={imageFiles.length}
|
||||
// i={i}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
|
||||
)}
|
||||
>
|
||||
{!isCreatedByUser ? (
|
||||
<Markdown content={text} message={message} showCursor={showCursor} />
|
||||
) : (
|
||||
<>{text}</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Unfinished Message Component
|
||||
const UnfinishedMessage = () => (
|
||||
|
|
|
@ -54,7 +54,7 @@ export default function HoverButtons({
|
|||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
'hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isCreatedByUser ? '' : 'active',
|
||||
hideEditButton ? 'opacity-0' : '',
|
||||
isEditing ? 'active bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
|
||||
|
@ -68,8 +68,8 @@ export default function HoverButtons({
|
|||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button ml-0 flex items-center gap-1.5 rounded-md p-1 pl-0 text-xs hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isCreatedByUser ? '' : 'active',
|
||||
'ml-0 flex items-center gap-1.5 rounded-md p-1 pl-0 text-xs hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
onClick={() => copyToClipboard(setIsCopied)}
|
||||
type="button"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useEffect } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Plugin } from '~/components/Messages/Content';
|
||||
import MessageContent from './Content/MessageContent';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
|
@ -11,9 +12,11 @@ import { useChatContext } from '~/Providers';
|
|||
import MultiMessage from './MultiMessage';
|
||||
import HoverButtons from './HoverButtons';
|
||||
import SubRow from './SubRow';
|
||||
// import { cn } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const autoScroll = useRecoilValue(store.autoScroll);
|
||||
const {
|
||||
message,
|
||||
scrollToBottom,
|
||||
|
@ -27,7 +30,6 @@ export default function Message(props: TMessageProps) {
|
|||
const {
|
||||
ask,
|
||||
regenerate,
|
||||
autoScroll,
|
||||
abortScroll,
|
||||
isSubmitting,
|
||||
conversation,
|
||||
|
@ -121,8 +123,8 @@ export default function Message(props: TMessageProps) {
|
|||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
>
|
||||
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 md:py-6">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:gap-6 md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
|
||||
<div className="} group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
|
@ -136,7 +138,12 @@ export default function Message(props: TMessageProps) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-turn relative flex w-[calc(100%-50px)] w-full flex-col lg:w-[calc(100%-36px)]">
|
||||
<div
|
||||
className={cn('relative flex w-full flex-col', isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className="select-none font-semibold">
|
||||
{isCreatedByUser ? 'You' : message.sender}
|
||||
</div>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
{/* Legacy Plugins */}
|
||||
|
|
|
@ -2,10 +2,10 @@ import { useLayoutEffect, useState, useRef, useCallback } from 'react';
|
|||
import type { ReactNode } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
import { useScreenshot, useScrollToRef } from '~/hooks';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { useScrollToRef } from '~/hooks';
|
||||
|
||||
export default function MessagesView({
|
||||
messagesTree: _messagesTree,
|
||||
|
@ -21,8 +21,7 @@ export default function MessagesView({
|
|||
const { conversation, showPopover, setAbortScroll } = useChatContext();
|
||||
const { conversationId } = conversation ?? {};
|
||||
|
||||
// TODO: screenshot target ref
|
||||
// const { screenshotTargetRef } = useScreenshot();
|
||||
const { screenshotTargetRef } = useScreenshot();
|
||||
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
if (!scrollableRef.current) {
|
||||
|
@ -82,26 +81,28 @@ export default function MessagesView({
|
|||
) : (
|
||||
<>
|
||||
{Header && Header}
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messageId={conversationId ?? null}
|
||||
messagesTree={_messagesTree}
|
||||
scrollToBottom={scrollToBottom}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() =>
|
||||
showScrollButton &&
|
||||
!showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
}
|
||||
</CSSTransition>
|
||||
<div ref={screenshotTargetRef}>
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messageId={conversationId ?? null}
|
||||
messagesTree={_messagesTree}
|
||||
scrollToBottom={scrollToBottom}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() =>
|
||||
showScrollButton &&
|
||||
!showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
}
|
||||
</CSSTransition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
|
|
17
client/src/components/Chat/Presentation.tsx
Normal file
17
client/src/components/Chat/Presentation.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import DragDropOverlay from './Input/Files/DragDropOverlay';
|
||||
import { useDragHelpers } from '~/hooks';
|
||||
|
||||
export default function Presentation({ children }: { children: React.ReactNode }) {
|
||||
const { isOver, canDrop, drop } = useDragHelpers();
|
||||
const isActive = canDrop && isOver;
|
||||
return (
|
||||
<div ref={drop} className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{children}
|
||||
{isActive && <DragDropOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -38,7 +38,12 @@ export default function Conversation({ conversation, retainView, toggleNav, i })
|
|||
|
||||
// set conversation to the new conversation
|
||||
if (conversation?.endpoint === 'gptPlugins') {
|
||||
const lastSelectedTools = JSON.parse(localStorage.getItem('lastSelectedTools') ?? '') || [];
|
||||
let lastSelectedTools = [];
|
||||
try {
|
||||
lastSelectedTools = JSON.parse(localStorage.getItem('lastSelectedTools') ?? '') ?? [];
|
||||
} catch (e) {
|
||||
// console.error(e);
|
||||
}
|
||||
navigateToConvo({ ...conversation, tools: lastSelectedTools });
|
||||
} else {
|
||||
navigateToConvo(conversation);
|
||||
|
|
|
@ -75,7 +75,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
placeholder={localize('com_endpoint_prompt_prefix_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex max-h-[300px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -122,7 +122,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
|
||||
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_top_p')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', '0.7')})
|
||||
|
|
|
@ -76,7 +76,7 @@ export default function Settings({ conversation, setOption, readonly }: TSetting
|
|||
placeholder={localize('com_endpoint_bing_context_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex max-h-[300px] min-h-[100px] w-full resize-none px-3 py-2',
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
|
||||
)}
|
||||
/>
|
||||
<small className="mb-5 text-black dark:text-white">{`${localize(
|
||||
|
|
|
@ -42,7 +42,7 @@ function Examples({ readonly, examples, setExample, addExample, removeExample }:
|
|||
placeholder="Set example input. Example is ignored if empty."
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex max-h-[300px] min-h-[75px] w-full resize-none px-3 py-2 ',
|
||||
'flex max-h-[138px] min-h-[75px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -79,7 +79,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
placeholder={localize('com_endpoint_prompt_prefix_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex max-h-[300px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -87,7 +87,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
className={cn(
|
||||
defaultTextProps,
|
||||
'dark:bg-gray-700 dark:hover:bg-gray-700/60 dark:focus:bg-gray-700',
|
||||
'flex max-h-[300px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -100,7 +100,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex max-h-[300px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery, alternateName } from 'librechat-data-provider';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { DropdownMenuRadioItem } from '~/components';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
import { SetKeyDialog } from '../SetKeyDialog';
|
||||
import { alternateName } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { RevokeKeysButton } from '~/components/Nav';
|
||||
|
@ -7,7 +7,6 @@ import { Dialog, Dropdown } from '~/components/ui';
|
|||
import { useUserKey, useLocalize } from '~/hooks';
|
||||
import GoogleConfig from './GoogleConfig';
|
||||
import OpenAIConfig from './OpenAIConfig';
|
||||
import { alternateName } from '~/common';
|
||||
import OtherConfig from './OtherConfig';
|
||||
import HelpText from './HelpText';
|
||||
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import EndpointOptionsDialog from '../Endpoints/EndpointOptionsDialog';
|
||||
import { Plugin } from '~/components/svg';
|
||||
import { alternateName } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import { useState, forwardRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Download } from 'lucide-react';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
import ExportModel from './ExportModel';
|
||||
|
||||
import store from '~/store';
|
||||
import ExportModal from './ExportModal';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
const ExportConversation = forwardRef(() => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
@ -38,7 +36,7 @@ const ExportConversation = forwardRef(() => {
|
|||
{localize('com_nav_export_conversation')}
|
||||
</button>
|
||||
|
||||
<ExportModel open={open} onOpenChange={setOpen} />
|
||||
<ExportModal open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,27 +1,33 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue, useRecoilCallback } from 'recoil';
|
||||
import filenamify from 'filenamify';
|
||||
import exportFromJSON from 'export-from-json';
|
||||
import download from 'downloadjs';
|
||||
import { Dialog, DialogButton, Input, Label, Checkbox, Dropdown } from '~/components/ui/';
|
||||
import filenamify from 'filenamify';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { useEffect, useState } from 'react';
|
||||
import exportFromJSON from 'export-from-json';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useGetMessagesByConvoId } from 'librechat-data-provider';
|
||||
import { Dialog, DialogButton, Input, Label, Checkbox, Dropdown } from '~/components/ui/';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, cleanupPreset } from '~/utils/';
|
||||
import { useScreenshot, useLocalize } from '~/hooks';
|
||||
import { buildTree } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ExportModel({ open, onOpenChange }) {
|
||||
export default function ExportModal({ open, onOpenChange, conversation }) {
|
||||
const { captureScreenshot } = useScreenshot();
|
||||
const localize = useLocalize();
|
||||
|
||||
const [filename, setFileName] = useState('');
|
||||
const [type, setType] = useState('');
|
||||
const [type, setType] = useState('Select a file type');
|
||||
|
||||
const [includeOptions, setIncludeOptions] = useState(true);
|
||||
const [exportBranches, setExportBranches] = useState(false);
|
||||
const [recursive, setRecursive] = useState(true);
|
||||
|
||||
const conversation = useRecoilValue(store.conversation) || {};
|
||||
const messagesTree = useRecoilValue(store.messagesTree) || [];
|
||||
const { data: messagesTree = null } = useGetMessagesByConvoId(conversation.conversationId ?? '', {
|
||||
select: (data) => {
|
||||
const dataTree = buildTree(data, false);
|
||||
return dataTree?.length === 0 ? null : dataTree ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const getSiblingIdx = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
|
@ -357,22 +363,11 @@ export default function ExportModel({ open, onOpenChange }) {
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 flex flex-col items-start justify-start gap-2">
|
||||
<div className="col-span-1 flex w-full flex-col items-start justify-start gap-2">
|
||||
<Label htmlFor="type" className="text-left text-sm font-medium">
|
||||
{localize('com_nav_export_type')}
|
||||
</Label>
|
||||
<Dropdown
|
||||
id="type"
|
||||
value={type}
|
||||
onChange={_setType}
|
||||
options={typeOptions}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex h-10 max-h-10 w-full resize-none',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
containerClassName="flex w-full resize-none"
|
||||
/>
|
||||
<Dropdown id="type" value={type} onChange={_setType} options={typeOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full gap-6 sm:grid-cols-2">
|
|
@ -1,2 +1,2 @@
|
|||
export { default as ExportConversation } from './ExportConversation';
|
||||
export { default as ExportModel } from './ExportModel';
|
||||
export { default as ExportModal } from './ExportModal';
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import { Download } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment, useState, memo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { ExportModel } from './ExportConversation';
|
||||
import Settings from './Settings';
|
||||
import NavLink from './NavLink';
|
||||
import Logout from './Logout';
|
||||
import { ExportModal } from './ExportConversation';
|
||||
import { LinkIcon, GearIcon } from '~/components';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Settings from './Settings';
|
||||
import NavLink from './NavLink';
|
||||
import Logout from './Logout';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
import store from '~/store';
|
||||
|
||||
export default function NavLinks() {
|
||||
function NavLinks() {
|
||||
const localize = useLocalize();
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const balanceQuery = useGetUserBalance({
|
||||
|
@ -23,14 +25,23 @@ export default function NavLinks() {
|
|||
});
|
||||
const [showExports, setShowExports] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const localize = useLocalize();
|
||||
|
||||
const conversation = useRecoilValue(store.conversation) ?? ({} as TConversation);
|
||||
let conversation;
|
||||
const activeConvo = useRecoilValue(store.conversationByIndex(0));
|
||||
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
|
||||
if (location.state?.from?.pathname.includes('/chat')) {
|
||||
conversation = globalConvo;
|
||||
} else {
|
||||
conversation = activeConvo;
|
||||
}
|
||||
|
||||
const exportable =
|
||||
conversation?.conversationId &&
|
||||
conversation?.conversationId !== 'new' &&
|
||||
conversation?.conversationId !== 'search';
|
||||
conversation &&
|
||||
conversation.conversationId &&
|
||||
conversation.conversationId !== 'new' &&
|
||||
conversation.conversationId !== 'search';
|
||||
|
||||
console.log('NavLinks', conversation, exportable);
|
||||
|
||||
const clickHandler = () => {
|
||||
if (exportable) {
|
||||
|
@ -124,8 +135,12 @@ export default function NavLinks() {
|
|||
</>
|
||||
)}
|
||||
</Menu>
|
||||
{showExports && <ExportModel open={showExports} onOpenChange={setShowExports} />}
|
||||
{showExports && (
|
||||
<ExportModal open={showExports} onOpenChange={setShowExports} conversation={conversation} />
|
||||
)}
|
||||
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NavLinks);
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
import { cn } from '~/utils';
|
||||
|
||||
export default function MinimalPlugin({ className = '' }) {
|
||||
export default function MinimalPlugin({ size, className = 'icon-md' }) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('icon-md', className)}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
|
|
@ -37,7 +37,11 @@ const DialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDivE
|
|||
const defaultSelect =
|
||||
'bg-gray-900 text-white transition-colors hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900';
|
||||
return (
|
||||
<DialogContent ref={ref} className={cn('shadow-2xl dark:bg-gray-900', className || '')}>
|
||||
<DialogContent
|
||||
ref={ref}
|
||||
className={cn('shadow-2xl dark:bg-gray-900', className || '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader className={cn('sm:pb-2', headerClassName ?? '')}>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||
{title}
|
||||
|
|
|
@ -19,6 +19,10 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
// necessary to reset the input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
|
|
1
client/src/data-provider/index.ts
Normal file
1
client/src/data-provider/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './mutations';
|
39
client/src/data-provider/mutations.ts
Normal file
39
client/src/data-provider/mutations.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type {
|
||||
FileUploadResponse,
|
||||
UploadMutationOptions,
|
||||
FileUploadBody,
|
||||
DeleteFilesResponse,
|
||||
DeleteFilesBody,
|
||||
DeleteMutationOptions,
|
||||
} from 'librechat-data-provider';
|
||||
import { dataService, MutationKeys } from 'librechat-data-provider';
|
||||
|
||||
export const useUploadImageMutation = (
|
||||
options?: UploadMutationOptions,
|
||||
): UseMutationResult<
|
||||
FileUploadResponse, // response data
|
||||
unknown, // error
|
||||
FileUploadBody, // request
|
||||
unknown // context
|
||||
> => {
|
||||
return useMutation([MutationKeys.imageUpload], {
|
||||
mutationFn: (body: FileUploadBody) => dataService.uploadImage(body.formData),
|
||||
...(options || {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFilesMutation = (
|
||||
options?: DeleteMutationOptions,
|
||||
): UseMutationResult<
|
||||
DeleteFilesResponse, // response data
|
||||
unknown, // error
|
||||
DeleteFilesBody, // request
|
||||
unknown // context
|
||||
> => {
|
||||
return useMutation([MutationKeys.fileDelete], {
|
||||
mutationFn: (body: DeleteFilesBody) => dataService.deleteFiles(body.files),
|
||||
...(options || {}),
|
||||
});
|
||||
};
|
|
@ -65,7 +65,7 @@ const AuthContextProvider = ({
|
|||
loginUser.mutate(data, {
|
||||
onSuccess: (data: TLoginResponse) => {
|
||||
const { user, token } = data;
|
||||
setUserContext({ token, isAuthenticated: true, user, redirect: '/chat/new' });
|
||||
setUserContext({ token, isAuthenticated: true, user, redirect: '/c/new' });
|
||||
},
|
||||
onError: (error: TResError | unknown) => {
|
||||
const resError = error as TResError;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { createContext, useRef, useContext, RefObject } from 'react';
|
||||
import { toCanvas } from 'html-to-image';
|
||||
import { ThemeContext } from '~/hooks/ThemeContext';
|
||||
|
||||
type ScreenshotContextType = {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
|
@ -9,14 +10,21 @@ const ScreenshotContext = createContext<ScreenshotContextType>({});
|
|||
|
||||
export const useScreenshot = () => {
|
||||
const { ref } = useContext(ScreenshotContext);
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const takeScreenShot = async (node: HTMLElement) => {
|
||||
if (!node) {
|
||||
throw new Error('You should provide correct html node.');
|
||||
}
|
||||
|
||||
let isDark = theme === 'dark';
|
||||
if (theme === 'system') {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
const backgroundColor = isDark ? '#343541' : 'white';
|
||||
const canvas = await toCanvas(node);
|
||||
const croppedCanvas = document.createElement('canvas');
|
||||
const croppedCanvasContext = croppedCanvas.getContext('2d');
|
||||
const croppedCanvasContext = croppedCanvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
// init data
|
||||
const cropPositionTop = 0;
|
||||
const cropPositionLeft = 0;
|
||||
|
@ -26,6 +34,9 @@ export const useScreenshot = () => {
|
|||
croppedCanvas.width = cropWidth;
|
||||
croppedCanvas.height = cropHeight;
|
||||
|
||||
croppedCanvasContext.fillStyle = backgroundColor;
|
||||
croppedCanvasContext?.fillRect(0, 0, cropWidth, cropHeight);
|
||||
|
||||
croppedCanvasContext?.drawImage(canvas, cropPositionLeft, cropPositionTop);
|
||||
|
||||
const base64Image = croppedCanvas.toDataURL('image/png', 1);
|
||||
|
|
|
@ -22,16 +22,12 @@ import useUserKey from './useUserKey';
|
|||
import store from '~/store';
|
||||
|
||||
// this to be set somewhere else
|
||||
export default function useChatHelpers(index = 0, paramId) {
|
||||
export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
||||
const [files, setFiles] = useState(new Map<string, ExtendedFile>());
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
// const tempConvo = {
|
||||
// endpoint: null,
|
||||
// conversationId: null,
|
||||
// jailbreak: false,
|
||||
// examples: [],
|
||||
// tools: [],
|
||||
// };
|
||||
|
||||
const { newConversation } = useNewConvo(index);
|
||||
const { useCreateConversationAtom } = store;
|
||||
|
@ -40,10 +36,6 @@ export default function useChatHelpers(index = 0, paramId) {
|
|||
|
||||
const queryParam = paramId === 'new' ? paramId : conversationId ?? paramId ?? '';
|
||||
|
||||
// if (!queryParam && paramId && paramId !== 'new') {
|
||||
|
||||
// }
|
||||
|
||||
/* Messages: here simply to fetch, don't export and use `getMessages()` instead */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { data: _messages } = useGetMessagesByConvoId(conversationId ?? '', {
|
||||
|
@ -97,10 +89,6 @@ export default function useChatHelpers(index = 0, paramId) {
|
|||
[queryClient],
|
||||
);
|
||||
|
||||
// const getConvos = useCallback(() => {
|
||||
// return queryClient.getQueryData<TGetConversationsResponse>([QueryKeys.allConversations, { pageNumber: '1', active: true }]);
|
||||
// }, [queryClient]);
|
||||
|
||||
const invalidateConvos = useCallback(() => {
|
||||
queryClient.invalidateQueries([QueryKeys.allConversations, { active: true }]);
|
||||
}, [queryClient]);
|
||||
|
@ -195,6 +183,24 @@ export default function useChatHelpers(index = 0, paramId) {
|
|||
error: false,
|
||||
};
|
||||
|
||||
const parentMessage = currentMessages?.find(
|
||||
(msg) => msg.messageId === latestMessage?.parentMessageId,
|
||||
);
|
||||
const reuseFiles = isRegenerate && parentMessage?.files;
|
||||
if (reuseFiles && parentMessage.files?.length) {
|
||||
currentMsg.files = parentMessage.files;
|
||||
setFiles(new Map());
|
||||
} else if (files.size > 0) {
|
||||
currentMsg.files = Array.from(files.values()).map((file) => ({
|
||||
file_id: file.file_id,
|
||||
filepath: file.filepath,
|
||||
type: file.type || '', // Ensure type is not undefined
|
||||
height: file.height,
|
||||
width: file.width,
|
||||
}));
|
||||
setFiles(new Map());
|
||||
}
|
||||
|
||||
// construct the placeholder response message
|
||||
const generation = editedText ?? latestMessage?.text ?? '';
|
||||
const responseText = isEditOrContinue
|
||||
|
@ -311,7 +317,6 @@ export default function useChatHelpers(index = 0, paramId) {
|
|||
);
|
||||
const [showPopover, setShowPopover] = useRecoilState(store.showPopoverFamily(index));
|
||||
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScrollFamily(index));
|
||||
const [autoScroll, setAutoScroll] = useRecoilState(store.autoScrollFamily(index));
|
||||
const [preset, setPreset] = useRecoilState(store.presetByIndex(index));
|
||||
const [textareaHeight, setTextareaHeight] = useRecoilState(store.textareaHeightFamily(index));
|
||||
const [optionSettings, setOptionSettings] = useRecoilState(store.optionSettingsFamily(index));
|
||||
|
@ -319,8 +324,6 @@ export default function useChatHelpers(index = 0, paramId) {
|
|||
store.showAgentSettingsFamily(index),
|
||||
);
|
||||
|
||||
const [files, setFiles] = useState<ExtendedFile[]>([]);
|
||||
|
||||
return {
|
||||
newConversation,
|
||||
conversation,
|
||||
|
@ -347,8 +350,6 @@ export default function useChatHelpers(index = 0, paramId) {
|
|||
setShowPopover,
|
||||
abortScroll,
|
||||
setAbortScroll,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
showBingToneSetting,
|
||||
setShowBingToneSetting,
|
||||
preset,
|
||||
|
@ -362,5 +363,7 @@ export default function useChatHelpers(index = 0, paramId) {
|
|||
files,
|
||||
setFiles,
|
||||
invalidateConvos,
|
||||
filesLoading,
|
||||
setFilesLoading,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,79 +1,38 @@
|
|||
import { useDrop } from 'react-dnd';
|
||||
import { NativeTypes } from 'react-dnd-html5-backend';
|
||||
import type { DropTargetMonitor } from 'react-dnd';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import useFileHandling from './useFileHandling';
|
||||
|
||||
export default function useDragHelpers(
|
||||
setFiles: React.Dispatch<React.SetStateAction<ExtendedFile[]>>,
|
||||
) {
|
||||
const addFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => [...currentFiles, newFile]);
|
||||
};
|
||||
export default function useDragHelpers() {
|
||||
const { files, handleFiles } = useFileHandling();
|
||||
const [{ canDrop, isOver }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: [NativeTypes.FILE],
|
||||
drop(item: { files: File[] }) {
|
||||
console.log('drop', item.files);
|
||||
handleFiles(item.files);
|
||||
},
|
||||
canDrop() {
|
||||
// console.log('canDrop', item.files, item.items);
|
||||
return true;
|
||||
},
|
||||
// hover() {
|
||||
// // console.log('hover', item.files, item.items);
|
||||
// },
|
||||
collect: (monitor: DropTargetMonitor) => {
|
||||
// const item = monitor.getItem() as File[];
|
||||
// if (item) {
|
||||
// console.log('collect', item.files, item.items);
|
||||
// }
|
||||
|
||||
const replaceFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) =>
|
||||
currentFiles.map((f) => (f.preview === newFile.preview ? newFile : f)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleFiles = (files: FileList | File[]) => {
|
||||
Array.from(files).forEach((originalFile) => {
|
||||
if (!originalFile.type.startsWith('image/')) {
|
||||
// TODO: showToast('Only image files are supported');
|
||||
// TODO: handle other file types
|
||||
return;
|
||||
}
|
||||
const preview = URL.createObjectURL(originalFile);
|
||||
const extendedFile: ExtendedFile = {
|
||||
file: originalFile,
|
||||
preview,
|
||||
progress: 0,
|
||||
};
|
||||
addFile(extendedFile);
|
||||
|
||||
// async processing
|
||||
if (originalFile.type.startsWith('image/')) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
extendedFile.width = img.width;
|
||||
extendedFile.height = img.height;
|
||||
extendedFile.progress = 1; // Update loading status
|
||||
replaceFile(extendedFile);
|
||||
URL.revokeObjectURL(preview); // Clean up the object URL
|
||||
return {
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
};
|
||||
img.src = preview;
|
||||
} else {
|
||||
// TODO: non-image files
|
||||
// extendedFile.progress = false;
|
||||
// replaceFile(extendedFile);
|
||||
}
|
||||
});
|
||||
};
|
||||
const [{ canDrop, isOver }, drop] = useDrop(() => ({
|
||||
accept: [NativeTypes.FILE],
|
||||
drop(item: { files: File[] }) {
|
||||
console.log('drop', item.files);
|
||||
handleFiles(item.files);
|
||||
},
|
||||
canDrop() {
|
||||
// console.log('canDrop', item.files, item.items);
|
||||
return true;
|
||||
},
|
||||
// hover() {
|
||||
// // console.log('hover', item.files, item.items);
|
||||
// },
|
||||
collect: (monitor: DropTargetMonitor) => {
|
||||
// const item = monitor.getItem() as File[];
|
||||
// if (item) {
|
||||
// console.log('collect', item.files, item.items);
|
||||
// }
|
||||
|
||||
return {
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
[files],
|
||||
);
|
||||
|
||||
return {
|
||||
canDrop,
|
||||
|
|
|
@ -1,56 +1,255 @@
|
|||
import { v4 } from 'uuid';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useToastContext } from '~/Providers/ToastContext';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { useUploadImageMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
|
||||
const sizeMB = 20;
|
||||
const maxSize = 25;
|
||||
const fileLimit = 10;
|
||||
const sizeLimit = sizeMB * 1024 * 1024; // 20 MB
|
||||
const totalSizeLimit = maxSize * 1024 * 1024; // 25 MB
|
||||
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
|
||||
const useFileHandling = () => {
|
||||
const { files, setFiles } = useChatContext();
|
||||
const { showToast } = useToastContext();
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
||||
const { files, setFiles, setFilesLoading } = useChatContext();
|
||||
|
||||
const displayToast = useCallback(() => {
|
||||
if (errors.length > 1) {
|
||||
const errorList = Array.from(new Set(errors))
|
||||
.map((e, i) => `${i > 0 ? '• ' : ''}${e}\n`)
|
||||
.join('');
|
||||
showToast({
|
||||
message: errorList,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
duration: 5000,
|
||||
});
|
||||
} else if (errors.length === 1) {
|
||||
showToast({
|
||||
message: errors[0],
|
||||
severity: NotificationSeverity.ERROR,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
setErrors([]);
|
||||
}, [errors, showToast]);
|
||||
|
||||
const debouncedDisplayToast = debounce(displayToast, 250);
|
||||
|
||||
useEffect(() => {
|
||||
if (errors.length > 0) {
|
||||
debouncedDisplayToast();
|
||||
}
|
||||
|
||||
return () => debouncedDisplayToast.cancel();
|
||||
}, [errors, debouncedDisplayToast]);
|
||||
|
||||
const addFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => [...currentFiles, newFile]);
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const replaceFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) =>
|
||||
currentFiles.map((f) => (f.preview === newFile.preview ? newFile : f)),
|
||||
);
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFiles = (files: FileList | File[]) => {
|
||||
Array.from(files).forEach((originalFile) => {
|
||||
if (!originalFile.type.startsWith('image/')) {
|
||||
// TODO: showToast('Only image files are supported');
|
||||
// TODO: handle other file types
|
||||
return;
|
||||
const updateFileById = (fileId: string, updates: Partial<ExtendedFile>) => {
|
||||
setFiles((currentFiles) => {
|
||||
if (!currentFiles.has(fileId)) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
const preview = URL.createObjectURL(originalFile);
|
||||
const extendedFile: ExtendedFile = {
|
||||
file: originalFile,
|
||||
preview,
|
||||
progress: 0,
|
||||
};
|
||||
addFile(extendedFile);
|
||||
|
||||
// async processing
|
||||
if (originalFile.type.startsWith('image/')) {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
const currentFile = updatedFiles.get(fileId);
|
||||
if (!currentFile) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
updatedFiles.set(fileId, { ...currentFile, ...updates });
|
||||
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFileById = (fileId: string) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
if (updatedFiles.has(fileId)) {
|
||||
updatedFiles.delete(fileId);
|
||||
} else {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
}
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadImage = useUploadImageMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('upload success', data);
|
||||
updateFileById(data.temp_file_id, {
|
||||
progress: 0.9,
|
||||
filepath: data.filepath,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const file = files.get(data.temp_file_id);
|
||||
updateFileById(data.temp_file_id, {
|
||||
progress: 1,
|
||||
file_id: data.file_id,
|
||||
temp_file_id: data.temp_file_id,
|
||||
filepath: data.filepath,
|
||||
// filepath: file?.preview,
|
||||
preview: file?.preview,
|
||||
type: data.type,
|
||||
height: data.height,
|
||||
width: data.width,
|
||||
filename: data.filename,
|
||||
});
|
||||
}, 300);
|
||||
},
|
||||
onError: (error, body) => {
|
||||
console.log('upload error', error);
|
||||
deleteFileById(body.file_id);
|
||||
setError('An error occurred while uploading the file.');
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (extendedFile: ExtendedFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', extendedFile.file);
|
||||
formData.append('file_id', extendedFile.file_id);
|
||||
if (extendedFile.width) {
|
||||
formData.append('width', extendedFile.width?.toString());
|
||||
}
|
||||
if (extendedFile.height) {
|
||||
formData.append('height', extendedFile.height?.toString());
|
||||
}
|
||||
|
||||
uploadImage.mutate({ formData, file_id: extendedFile.file_id });
|
||||
};
|
||||
|
||||
const validateFiles = (fileList: File[]) => {
|
||||
const existingFiles = Array.from(files.values());
|
||||
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
|
||||
const currentTotalSize = existingFiles.reduce((total, file) => total + file.size, 0);
|
||||
|
||||
if (fileList.length + files.size > fileLimit) {
|
||||
setError(`You can only upload up to ${fileLimit} files at a time.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const originalFile = fileList[i];
|
||||
if (!supportedTypes.includes(originalFile.type)) {
|
||||
setError('Currently, only JPEG, JPG, PNG, and WEBP files are supported.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (originalFile.size >= sizeLimit) {
|
||||
setError(`File size exceeds ${sizeMB} MB.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTotalSize + incomingTotalSize > totalSizeLimit) {
|
||||
setError(`The total size of the files cannot exceed ${maxSize} MB.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const combinedFilesInfo = [
|
||||
...existingFiles.map(
|
||||
(file) => `${file.file.name}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`,
|
||||
),
|
||||
...fileList.map((file) => `${file.name}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`),
|
||||
];
|
||||
|
||||
const uniqueFilesSet = new Set(combinedFilesInfo);
|
||||
|
||||
if (uniqueFilesSet.size !== combinedFilesInfo.length) {
|
||||
setError('Duplicate file detected.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleFiles = async (_files: FileList | File[]) => {
|
||||
const fileList = Array.from(_files);
|
||||
/* Validate files */
|
||||
let filesAreValid: boolean;
|
||||
try {
|
||||
filesAreValid = validateFiles(fileList);
|
||||
} catch (error) {
|
||||
console.error('file validation error', error);
|
||||
setError('An error occurred while validating the file.');
|
||||
return;
|
||||
}
|
||||
if (!filesAreValid) {
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Process files */
|
||||
fileList.forEach((originalFile) => {
|
||||
const file_id = v4();
|
||||
try {
|
||||
const preview = URL.createObjectURL(originalFile);
|
||||
let extendedFile: ExtendedFile = {
|
||||
file_id,
|
||||
file: originalFile,
|
||||
preview,
|
||||
progress: 0.2,
|
||||
size: originalFile.size,
|
||||
};
|
||||
|
||||
addFile(extendedFile);
|
||||
|
||||
// async processing
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
img.onload = async () => {
|
||||
extendedFile.width = img.width;
|
||||
extendedFile.height = img.height;
|
||||
extendedFile.progress = 1; // Update loading status
|
||||
extendedFile = {
|
||||
...extendedFile,
|
||||
progress: 0.6,
|
||||
};
|
||||
replaceFile(extendedFile);
|
||||
URL.revokeObjectURL(preview); // Clean up the object URL
|
||||
|
||||
await uploadFile(extendedFile);
|
||||
// This gets cleaned up in the Image component, after receiving the server image
|
||||
// URL.revokeObjectURL(preview);
|
||||
};
|
||||
img.src = preview;
|
||||
} else {
|
||||
// TODO: non-image files
|
||||
// extendedFile.progress = false;
|
||||
// replaceFile(extendedFile);
|
||||
} catch (error) {
|
||||
deleteFileById(file_id);
|
||||
console.log('file handling error', error);
|
||||
setError('An error occurred while processing the file.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.target.files) {
|
||||
setFilesLoading(true);
|
||||
handleFiles(event.target.files);
|
||||
// reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
209
client/src/hooks/useFileHandlingResize.ts
Normal file
209
client/src/hooks/useFileHandlingResize.ts
Normal file
|
@ -0,0 +1,209 @@
|
|||
import { v4 } from 'uuid';
|
||||
// import { useState } from 'react';
|
||||
import ImageBlobReduce from 'image-blob-reduce';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useUploadImageMutation } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
|
||||
const reducer = new ImageBlobReduce();
|
||||
const resolution = 'high';
|
||||
|
||||
const useFileHandling = () => {
|
||||
// const [errors, setErrors] = useState<unknown[]>([]);
|
||||
const { files, setFiles, setFilesLoading } = useChatContext();
|
||||
|
||||
const addFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const replaceFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileById = (fileId: string, updates: Partial<ExtendedFile>) => {
|
||||
setFiles((currentFiles) => {
|
||||
if (!currentFiles.has(fileId)) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
const currentFile = updatedFiles.get(fileId);
|
||||
updatedFiles.set(fileId, { ...currentFile, ...updates });
|
||||
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
// const deleteFile = (fileId: string) => {
|
||||
// setFiles((currentFiles) => {
|
||||
// const updatedFiles = new Map(currentFiles);
|
||||
// updatedFiles.delete(fileId);
|
||||
// return updatedFiles;
|
||||
// });
|
||||
// };
|
||||
|
||||
const deleteFileById = (fileId: string) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
if (updatedFiles.has(fileId)) {
|
||||
updatedFiles.delete(fileId);
|
||||
} else {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
}
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadImage = useUploadImageMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('upload success', data);
|
||||
updateFileById(data.temp_file_id, {
|
||||
progress: 0.9,
|
||||
filepath: data.filepath,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
updateFileById(data.temp_file_id, {
|
||||
progress: 1,
|
||||
filepath: data.filepath,
|
||||
});
|
||||
}, 300);
|
||||
},
|
||||
onError: (error, body) => {
|
||||
console.log('upload error', error);
|
||||
deleteFileById(body.file_id);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (extendedFile: ExtendedFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', extendedFile.file);
|
||||
formData.append('file_id', extendedFile.file_id);
|
||||
if (extendedFile.width) {
|
||||
formData.append('width', extendedFile.width?.toString());
|
||||
}
|
||||
if (extendedFile.height) {
|
||||
formData.append('height', extendedFile.height?.toString());
|
||||
}
|
||||
|
||||
uploadImage.mutate({ formData, file_id: extendedFile.file_id });
|
||||
};
|
||||
|
||||
const handleFiles = async (files: FileList | File[]) => {
|
||||
Array.from(files).forEach((originalFile) => {
|
||||
if (!originalFile.type.startsWith('image/')) {
|
||||
// TODO: showToast('Only image files are supported');
|
||||
// TODO: handle other file types
|
||||
return;
|
||||
}
|
||||
|
||||
// todo: Set File is loading
|
||||
|
||||
try {
|
||||
const preview = URL.createObjectURL(originalFile);
|
||||
let extendedFile: ExtendedFile = {
|
||||
file_id: v4(),
|
||||
file: originalFile,
|
||||
preview,
|
||||
progress: 0.2,
|
||||
};
|
||||
|
||||
addFile(extendedFile);
|
||||
|
||||
// async processing
|
||||
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
extendedFile.width = img.width;
|
||||
extendedFile.height = img.height;
|
||||
|
||||
let max = 512;
|
||||
|
||||
if (resolution === 'high') {
|
||||
max = extendedFile.height > extendedFile.width ? 768 : 2000;
|
||||
}
|
||||
|
||||
const reducedBlob = await reducer.toBlob(originalFile, {
|
||||
max,
|
||||
});
|
||||
|
||||
const resizedFile = new File([reducedBlob], originalFile.name, {
|
||||
type: originalFile.type,
|
||||
});
|
||||
|
||||
const resizedPreview = URL.createObjectURL(resizedFile);
|
||||
extendedFile = {
|
||||
...extendedFile,
|
||||
file: resizedFile,
|
||||
};
|
||||
|
||||
const resizedImg = new Image();
|
||||
resizedImg.onload = async () => {
|
||||
extendedFile = {
|
||||
...extendedFile,
|
||||
file: resizedFile,
|
||||
width: resizedImg.width,
|
||||
height: resizedImg.height,
|
||||
progress: 0.6,
|
||||
};
|
||||
|
||||
replaceFile(extendedFile);
|
||||
URL.revokeObjectURL(resizedPreview); // Clean up the object URL
|
||||
await uploadFile(extendedFile);
|
||||
};
|
||||
resizedImg.src = resizedPreview;
|
||||
URL.revokeObjectURL(preview); // Clean up the original object URL
|
||||
|
||||
/* TODO: send to backend server /api/files
|
||||
use React Query Mutation to upload file (TypeScript), we need to make the CommonJS api endpoint (expressjs) to accept file upload
|
||||
server needs the image file, which the server will convert to base64 to send to external API
|
||||
server will then employ a 'saving' or 'caching' strategy based on admin configuration (can be local, CDN, etc.)
|
||||
the expressjs server needs the following:
|
||||
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
width,
|
||||
height,
|
||||
|
||||
use onSuccess, onMutate handlers to update the file progress
|
||||
|
||||
we need the full api handling for this, including the server-side
|
||||
|
||||
*/
|
||||
};
|
||||
img.src = preview;
|
||||
} catch (error) {
|
||||
console.log('file handling error', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.target.files) {
|
||||
setFilesLoading(true);
|
||||
handleFiles(event.target.files);
|
||||
// reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleFileChange,
|
||||
handleFiles,
|
||||
files,
|
||||
setFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFileHandling;
|
|
@ -272,6 +272,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
}
|
||||
if (data.created) {
|
||||
message = {
|
||||
...message,
|
||||
...data.message,
|
||||
overrideParentMessageId: message?.overrideParentMessageId,
|
||||
};
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { TEndpointOption, getResponseSender } from 'librechat-data-provider';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import useFileHandling from './useFileHandling';
|
||||
|
||||
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
|
||||
|
||||
|
@ -12,9 +14,11 @@ export default function useTextarea({ setText, submitMessage }) {
|
|||
setShowBingToneSetting,
|
||||
textareaHeight,
|
||||
setTextareaHeight,
|
||||
setFilesLoading,
|
||||
} = useChatContext();
|
||||
const isComposing = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const { handleFiles } = useFileHandling();
|
||||
|
||||
const isNotAppendable = (latestMessage?.unfinished && !isSubmitting) || latestMessage?.error;
|
||||
const { conversationId, jailbreak } = conversation || {};
|
||||
|
@ -88,7 +92,9 @@ export default function useTextarea({ setText, submitMessage }) {
|
|||
return 'Edit your message or Regenerate.';
|
||||
}
|
||||
|
||||
return 'Message ChatGPT…';
|
||||
const sender = getResponseSender(conversation as TEndpointOption);
|
||||
|
||||
return `Message ${sender ? sender : 'ChatGPT'}…`;
|
||||
};
|
||||
|
||||
const onHeightChange = (height: number) => {
|
||||
|
@ -101,10 +107,19 @@ export default function useTextarea({ setText, submitMessage }) {
|
|||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length > 0) {
|
||||
e.preventDefault();
|
||||
setFilesLoading(true);
|
||||
handleFiles(e.clipboardData.files);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
inputRef,
|
||||
handleKeyDown,
|
||||
handleKeyUp,
|
||||
handlePaste,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
placeholder: getPlaceholderText(),
|
||||
|
|
|
@ -4,14 +4,18 @@ import type { TShowToast } from '~/common';
|
|||
import { NotificationSeverity } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useToast(timeoutDuration = 100) {
|
||||
export default function useToast(showDelay = 100) {
|
||||
const [toast, setToast] = useRecoilState(store.toastState);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const showTimerRef = useRef<number | null>(null);
|
||||
const hideTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
if (showTimerRef.current !== null) {
|
||||
clearTimeout(showTimerRef.current);
|
||||
}
|
||||
if (hideTimerRef.current !== null) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
@ -20,14 +24,24 @@ export default function useToast(timeoutDuration = 100) {
|
|||
message,
|
||||
severity = NotificationSeverity.SUCCESS,
|
||||
showIcon = true,
|
||||
duration = 3000, // default duration for the toast to be visible
|
||||
}: TShowToast) => {
|
||||
setToast({ ...toast, open: false });
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
// Clear existing timeouts
|
||||
if (showTimerRef.current !== null) {
|
||||
clearTimeout(showTimerRef.current);
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (hideTimerRef.current !== null) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
|
||||
// Timeout to show the toast
|
||||
showTimerRef.current = window.setTimeout(() => {
|
||||
setToast({ open: true, message, severity, showIcon });
|
||||
}, timeoutDuration);
|
||||
// Hides the toast after the specified duration
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
setToast((prevToast) => ({ ...prevToast, open: false }));
|
||||
}, duration);
|
||||
}, showDelay);
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
@ -29,6 +29,7 @@ export default {
|
|||
com_ui_of: 'of',
|
||||
com_ui_entries: 'Entries',
|
||||
com_ui_pay_per_call: 'All AI conversations in one place. Pay per call and not per month',
|
||||
com_ui_new_footer: 'All AI conversations in one place.',
|
||||
com_ui_enter: 'Enter',
|
||||
com_ui_submit: 'Submit',
|
||||
com_ui_upload_success: 'Successfully uploaded file',
|
||||
|
@ -177,6 +178,7 @@ export default {
|
|||
com_endpoint_set_custom_name: 'Set a custom name, in case you can find this preset',
|
||||
com_endpoint_preset: 'preset',
|
||||
com_endpoint_presets: 'presets',
|
||||
com_endpoint_preset_selected: 'Preset Active!',
|
||||
com_endpoint_preset_name: 'Preset Name',
|
||||
com_endpoint_new_topic: 'New Topic',
|
||||
com_endpoint: 'Endpoint',
|
||||
|
|
|
@ -67,7 +67,7 @@ export default function Chat() {
|
|||
onError: (error) => {
|
||||
console.error('Failed to fetch the conversation');
|
||||
console.error(error);
|
||||
navigate('/chat/new');
|
||||
navigate('/c/new');
|
||||
newConversation();
|
||||
setShouldNavigate(true);
|
||||
},
|
||||
|
@ -76,7 +76,7 @@ export default function Chat() {
|
|||
}
|
||||
// No current conversation and no conversationId
|
||||
else if (conversation === null) {
|
||||
navigate('/chat/new');
|
||||
navigate('/c/new');
|
||||
setShouldNavigate(true);
|
||||
}
|
||||
// Current conversationId is 'search'
|
||||
|
|
|
@ -24,7 +24,7 @@ export default function Search() {
|
|||
searchPlaceholderConversation();
|
||||
setSearchQuery(query);
|
||||
} else {
|
||||
navigate('/chat/new');
|
||||
navigate('/c/new');
|
||||
}
|
||||
} else if (conversation?.conversationId === 'search') {
|
||||
// jump to search page
|
||||
|
|
|
@ -46,7 +46,7 @@ export const router = createBrowserRouter([
|
|||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/chat/new" replace={true} />,
|
||||
element: <Navigate to="/c/new" replace={true} />,
|
||||
},
|
||||
{
|
||||
path: 'c/:conversationId?',
|
||||
|
|
|
@ -83,25 +83,6 @@ const textareaHeightFamily = atomFamily<number, string | number>({
|
|||
default: 56,
|
||||
});
|
||||
|
||||
const autoScrollFamily = atomFamily({
|
||||
key: 'autoScrollByIndex',
|
||||
default: localStorage.getItem('autoScroll') === 'true',
|
||||
effects: [
|
||||
({ setSelf, onSet }) => {
|
||||
const savedValue = localStorage.getItem('autoScroll');
|
||||
if (savedValue != null) {
|
||||
setSelf(savedValue === 'true');
|
||||
}
|
||||
|
||||
onSet((newValue: unknown) => {
|
||||
if (typeof newValue === 'boolean') {
|
||||
localStorage.setItem('autoScroll', newValue.toString());
|
||||
}
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
});
|
||||
|
||||
function useCreateConversationAtom(key: string | number) {
|
||||
const [keys, setKeys] = useRecoilState(conversationKeysAtom);
|
||||
const setConversation = useSetRecoilState(conversationByIndex(key));
|
||||
|
@ -127,7 +108,6 @@ export default {
|
|||
showAgentSettingsFamily,
|
||||
showBingToneSettingFamily,
|
||||
showPopoverFamily,
|
||||
autoScrollFamily,
|
||||
latestMessageFamily,
|
||||
textareaHeightFamily,
|
||||
allConversationsSelector,
|
||||
|
|
|
@ -1,21 +1,5 @@
|
|||
import { atom } from 'recoil';
|
||||
import { TModelsConfig, EModelEndpoint } from 'librechat-data-provider';
|
||||
const openAIModels = [
|
||||
'gpt-3.5-turbo-16k-0613',
|
||||
'gpt-3.5-turbo-16k',
|
||||
'gpt-4-1106-preview',
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-3.5-turbo-1106',
|
||||
'gpt-4-vision-preview',
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo-instruct-0914',
|
||||
'gpt-3.5-turbo-0613',
|
||||
'gpt-3.5-turbo-0301',
|
||||
'gpt-3.5-turbo-instruct',
|
||||
'gpt-4-0613',
|
||||
'text-davinci-003',
|
||||
'gpt-4-0314',
|
||||
];
|
||||
import { TModelsConfig, EModelEndpoint, openAIModels } from 'librechat-data-provider';
|
||||
|
||||
const fitlerAssistantModels = (str: string) => {
|
||||
return /gpt-4|gpt-3\\.5/i.test(str) && !/vision|instruct/i.test(str);
|
||||
|
|
|
@ -898,15 +898,16 @@ button {
|
|||
}
|
||||
|
||||
.btn {
|
||||
align-items: center;
|
||||
border-color: transparent;
|
||||
border-radius: 0.25rem;
|
||||
border-width: 1px;
|
||||
display: inline-flex;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
pointer-events: auto;
|
||||
align-items:center;
|
||||
border-color:transparent;
|
||||
border-radius:.5rem;
|
||||
border-width:1px;
|
||||
display:inline-flex;
|
||||
font-size:.875rem;
|
||||
font-weight:500;
|
||||
line-height:1.25rem;
|
||||
padding:.5rem .75rem;
|
||||
pointer-events:auto
|
||||
}
|
||||
.custom-btn {
|
||||
align-items: center;
|
||||
|
@ -1395,7 +1396,30 @@ html {
|
|||
height:100%
|
||||
}
|
||||
.markdown ol {
|
||||
counter-reset:item
|
||||
counter-reset:list-number;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
list-style-type:none;
|
||||
padding-left:0
|
||||
}
|
||||
.markdown ol>li {
|
||||
counter-increment:list-number;
|
||||
display:block;
|
||||
margin-bottom:0;
|
||||
margin-top:0;
|
||||
min-height:28px
|
||||
}
|
||||
.markdown ol>li:before {
|
||||
--tw-translate-x:-100%;
|
||||
--tw-numeric-spacing:tabular-nums;
|
||||
--tw-text-opacity:1;
|
||||
color:rgba(142,142,160,var(--tw-text-opacity));
|
||||
content:counters(list-number,".") ".";
|
||||
font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction);
|
||||
padding-right:.5rem;
|
||||
position:absolute;
|
||||
-webkit-transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))
|
||||
}
|
||||
.markdown ul li {
|
||||
display:block;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { alternateName } from '~/common';
|
||||
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
|
||||
|
||||
export const getPresetIcon = (preset: TPreset, Icon) => {
|
||||
return Icon({
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue