Compare commits
34 Commits
fix/avatar
...
feat/datat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9abe5d77e | ||
|
|
9ce9d404d7 | ||
|
|
c6df907734 | ||
|
|
1cbe3e1326 | ||
|
|
f3d4f9c890 | ||
|
|
3c61d5ca77 | ||
|
|
a081f0f4f4 | ||
|
|
b9ca23c606 | ||
|
|
9c5bbdaa28 | ||
|
|
1cd82247ce | ||
|
|
a43562de8a | ||
|
|
a16744d211 | ||
|
|
787a9fe0f5 | ||
|
|
b5d32be229 | ||
|
|
7b588a607b | ||
|
|
b4aa36b6f1 | ||
|
|
1fa6d3db27 | ||
|
|
57be7d0e0c | ||
|
|
18d5571b82 | ||
|
|
8b11fcaed1 | ||
|
|
e6c1f7eb5a | ||
|
|
78a0dd2884 | ||
|
|
905cef1ac2 | ||
|
|
a3de2245b1 | ||
|
|
76b34775f0 | ||
|
|
ecadc2ec88 | ||
|
|
2ae2ce0797 | ||
|
|
0c8598bd15 | ||
|
|
8b5f9104ef | ||
|
|
c5efa9c9d4 | ||
|
|
780e6a4766 | ||
|
|
cf2d35fd32 | ||
|
|
4a949b57a1 | ||
|
|
3bb23ba11d |
10
.env.example
10
.env.example
@@ -702,16 +702,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
|
||||
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
|
||||
|
||||
# Leader Election Configuration (for multi-instance deployments with Redis)
|
||||
# Duration in seconds that the leader lease is valid before it expires (default: 25)
|
||||
# LEADER_LEASE_DURATION=25
|
||||
# Interval in seconds at which the leader renews its lease (default: 10)
|
||||
# LEADER_RENEW_INTERVAL=10
|
||||
# Maximum number of retry attempts when renewing the lease fails (default: 3)
|
||||
# LEADER_RENEW_ATTEMPTS=3
|
||||
# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
|
||||
# LEADER_RENEW_RETRY_DELAY=0.5
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
#==================================================#
|
||||
|
||||
13
.github/workflows/cache-integration-tests.yml
vendored
13
.github/workflows/cache-integration-tests.yml
vendored
@@ -8,13 +8,12 @@ on:
|
||||
- release/*
|
||||
paths:
|
||||
- 'packages/api/src/cache/**'
|
||||
- 'packages/api/src/cluster/**'
|
||||
- 'redis-config/**'
|
||||
- '.github/workflows/cache-integration-tests.yml'
|
||||
|
||||
jobs:
|
||||
cache_integration_tests:
|
||||
name: Integration Tests that use actual Redis Cache
|
||||
name: Run Cache Integration Tests
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -67,15 +66,7 @@ jobs:
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache-integration:core
|
||||
|
||||
- name: Run cluster integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:cluster
|
||||
run: npm run test:cache:integration
|
||||
|
||||
- name: Stop Redis Cluster
|
||||
if: always()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.1-rc1
|
||||
# v0.8.0
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.1-rc1
|
||||
# v0.8.0
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -2,7 +2,7 @@ const { z } = require('zod');
|
||||
const axios = require('axios');
|
||||
const { Ollama } = require('ollama');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { resolveHeaders } = require('@librechat/api');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const { deriveBaseURL } = require('~/utils');
|
||||
@@ -44,7 +44,6 @@ class OllamaClient {
|
||||
constructor(options = {}) {
|
||||
const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
|
||||
this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
|
||||
this.headers = options.headers ?? {};
|
||||
/** @type {Ollama} */
|
||||
this.client = new Ollama({ host });
|
||||
}
|
||||
@@ -52,32 +51,27 @@ class OllamaClient {
|
||||
/**
|
||||
* Fetches Ollama models from the specified base API path.
|
||||
* @param {string} baseURL
|
||||
* @param {Object} [options] - Optional configuration
|
||||
* @param {Partial<IUser>} [options.user] - User object for header resolution
|
||||
* @param {Record<string, string>} [options.headers] - Headers to include in the request
|
||||
* @returns {Promise<string[]>} The Ollama models.
|
||||
* @throws {Error} Throws if the Ollama API request fails
|
||||
*/
|
||||
static async fetchModels(baseURL, options = {}) {
|
||||
static async fetchModels(baseURL) {
|
||||
let models = [];
|
||||
if (!baseURL) {
|
||||
return models;
|
||||
}
|
||||
try {
|
||||
const ollamaEndpoint = deriveBaseURL(baseURL);
|
||||
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
|
||||
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
models = response.data.models.map((tag) => tag.name);
|
||||
return models;
|
||||
} catch (error) {
|
||||
const logMessage =
|
||||
"Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn't start with `ollama` (case-insensitive).";
|
||||
logAxiosError({ message: logMessage, error });
|
||||
return [];
|
||||
}
|
||||
|
||||
const ollamaEndpoint = deriveBaseURL(baseURL);
|
||||
|
||||
const resolvedHeaders = resolveHeaders({
|
||||
headers: options.headers,
|
||||
user: options.user,
|
||||
});
|
||||
|
||||
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
|
||||
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
|
||||
headers: resolvedHeaders,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const models = response.data.models.map((tag) => tag.name);
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,17 +22,17 @@
|
||||
'\n' +
|
||||
'Celestia had the power to grant one wish to anyone who dared to find her abode. Ethan, captivated by the tales he had read and yearning for something greater, approached the cottage with trepidation. When he shared his desire to embark on a grand adventure, Celestia smiled warmly and agreed to grant his wish.\n' +
|
||||
'\n' +
|
||||
'With a wave of her wand and a sprinkle of stardust, Celestia bestowed upon Ethan a magical necklace. This necklace, adorned with a rare gemstone called the Eye of Imagination, had the power to turn dreams and imagination into reality. From that moment forward, Ethan\'s every thought and idea became manifest.\n' +
|
||||
"With a wave of her wand and a sprinkle of stardust, Celestia bestowed upon Ethan a magical necklace. This necklace, adorned with a rare gemstone called the Eye of Imagination, had the power to turn dreams and imagination into reality. From that moment forward, Ethan's every thought and idea became manifest.\n" +
|
||||
'\n' +
|
||||
'Energized by this newfound power, Ethan continued his journey, encountering mythical creatures, solving riddles, and overcoming treacherous obstacles along the way. With the Eye of Imagination, he brought life to ancient statues, unlocked hidden doors, and even tamed fiery dragons.\n' +
|
||||
'\n' +
|
||||
'As days turned into weeks and weeks into months, Ethan became wiser and more in tune with the world around him. He learned that true adventure was not merely about seeking thrills and conquering the unknown, but also about fostering compassion, friendship, and a deep appreciation for the beauty of the ordinary.\n' +
|
||||
'\n' +
|
||||
'Eventually, Ethan\'s journey led him back to his village. With the Eye of Imagination, he transformed the village into a place of wonders and endless possibilities. Fields blossomed into vibrant gardens, simple tools turned into intricate works of art, and the villagers felt a renewed sense of hope and inspiration.\n' +
|
||||
"Eventually, Ethan's journey led him back to his village. With the Eye of Imagination, he transformed the village into a place of wonders and endless possibilities. Fields blossomed into vibrant gardens, simple tools turned into intricate works of art, and the villagers felt a renewed sense of hope and inspiration.\n" +
|
||||
'\n' +
|
||||
'Ethan, now known as the Village Magician, realized that the true magic lied within everyone\'s hearts. He taught the villagers to embrace their creativity, to dream big, and to never underestimate the power of imagination. And so, the village flourished, becoming a beacon of wonder and creativity for all to see.\n' +
|
||||
"Ethan, now known as the Village Magician, realized that the true magic lied within everyone's hearts. He taught the villagers to embrace their creativity, to dream big, and to never underestimate the power of imagination. And so, the village flourished, becoming a beacon of wonder and creativity for all to see.\n" +
|
||||
'\n' +
|
||||
'In the years that followed, Ethan\'s adventures continued, though mostly within the confines of his beloved village. But he never forgot the thrill of that first grand adventure. And every now and then, when looking up at the starry night sky, he would allow his mind to wander, knowing that the greatest adventures were still waiting to be discovered.',
|
||||
"In the years that followed, Ethan's adventures continued, though mostly within the confines of his beloved village. But he never forgot the thrill of that first grand adventure. And every now and then, when looking up at the starry night sky, he would allow his mind to wander, knowing that the greatest adventures were still waiting to be discovered.",
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
@@ -41,30 +41,30 @@
|
||||
'\n' +
|
||||
'Once there was a young lad by the name of Ethan, raised in a little hamlet nestled betwixt the verdant knolls, who possessed an irrepressible yearning for knowledge, a thirst unquenchable and a spirit teeming with curiosity. As the golden sun bathed the bucolic land in its effulgent light, he would tread through the village, his ears attuned to the tales spun by the townsfolk, his eyes absorbing the tapestry woven by the world surrounding him.\n' +
|
||||
'\n' +
|
||||
'One radiant day, whilst exploring the periphery of the settlement, Ethan chanced upon a timeworn tome, ensconced amidst the roots of an ancient oak, cloaked in the shroud of neglect. The dust gathered upon it spoke of time\'s relentless march. A book of fairy tales – garnished with vivid descriptions of mystical woods, fantastical beasts, and ventures daring beyond the ordinary humdrum existence. Intrigued and beguiled, Ethan pried open the weathered pages and succumbed to their beckoning whispers.\n' +
|
||||
"One radiant day, whilst exploring the periphery of the settlement, Ethan chanced upon a timeworn tome, ensconced amidst the roots of an ancient oak, cloaked in the shroud of neglect. The dust gathered upon it spoke of time's relentless march. A book of fairy tales – garnished with vivid descriptions of mystical woods, fantastical beasts, and ventures daring beyond the ordinary humdrum existence. Intrigued and beguiled, Ethan pried open the weathered pages and succumbed to their beckoning whispers.\n" +
|
||||
'\n' +
|
||||
'In each tale, he was transported to a realm of enchantment and wonderment, inexorably tugging at the strings of his yearning for peripatetic exploration. Inspired by the narratives he had devoured, Ethan resolved to bid adieu to kinfolk and embark upon a sojourn, with dreams of procuring a firsthand glimpse into the domain of mystique that lay beyond the village\'s circumscribed boundary.\n' +
|
||||
"In each tale, he was transported to a realm of enchantment and wonderment, inexorably tugging at the strings of his yearning for peripatetic exploration. Inspired by the narratives he had devoured, Ethan resolved to bid adieu to kinfolk and embark upon a sojourn, with dreams of procuring a firsthand glimpse into the domain of mystique that lay beyond the village's circumscribed boundary.\n" +
|
||||
'\n' +
|
||||
'Thus, he bade tearful farewells, girding himself for a path that guided him to a dense and captivating woodland, whispered of as a sanctuary to mythical beings and clandestine troves of treasures. As Ethan plunged deeper into the heart of the arboreal labyrinth, he felt a palpable surge of electricity, as though the sylvan sentinels whispered enigmatic secrets that only the perceptive ear could discern.\n' +
|
||||
'\n' +
|
||||
'It wasn\'t long before his path intertwined with that of a capricious sprite christened Sparkle, bearing an impish grin and eyes sparkling with mischief. Sparkle played the role of Virgil to Ethan\'s Dante, guiding him through the intricate tapestry of arboreal scions, issuing warnings of perils concealed and spinning tales of ancient entities that called this very bosky enclave home.\n' +
|
||||
"It wasn't long before his path intertwined with that of a capricious sprite christened Sparkle, bearing an impish grin and eyes sparkling with mischief. Sparkle played the role of Virgil to Ethan's Dante, guiding him through the intricate tapestry of arboreal scions, issuing warnings of perils concealed and spinning tales of ancient entities that called this very bosky enclave home.\n" +
|
||||
'\n' +
|
||||
'Together, they stumbled upon a luminous lake, its shimmering waters imbued with a celestial light. At the center lay a diminutive island, upon which reposed a cottage fashioned from tender petals and verdant leaves. It belonged to an ancient sorceress of considerable wisdom, Celestia by name.\n' +
|
||||
'\n' +
|
||||
'Celestia, with her power to bestow a single wish on any intrepid soul who happened upon her abode, met Ethan\'s desire with a congenial nod, his fervor for a grand expedition not lost on her penetrating gaze. In response, she bequeathed unto him a necklace of magical manufacture – adorned with the rare gemstone known as the Eye of Imagination – whose very essence transformed dreams into vivid reality. From that moment forward, not a single cogitation nor nebulous fanciful notion of Ethan\'s ever lacked physicality.\n' +
|
||||
"Celestia, with her power to bestow a single wish on any intrepid soul who happened upon her abode, met Ethan's desire with a congenial nod, his fervor for a grand expedition not lost on her penetrating gaze. In response, she bequeathed unto him a necklace of magical manufacture – adorned with the rare gemstone known as the Eye of Imagination – whose very essence transformed dreams into vivid reality. From that moment forward, not a single cogitation nor nebulous fanciful notion of Ethan's ever lacked physicality.\n" +
|
||||
'\n' +
|
||||
'Energized by this newfound potency, Ethan continued his sojourn, encountering mythical creatures, unraveling cerebral enigmas, and braving perils aplenty along the winding roads of destiny. Armed with the Eye of Imagination, he brought forth life from immobile statuary, unlocked forbidding portals, and even tamed the ferocious beasts of yore – their fiery breath reduced to a whisper.\n' +
|
||||
'\n' +
|
||||
'As the weeks metamorphosed into months, Ethan grew wiser and more attuned to the ebb and flow of the world enveloping him. He gleaned that true adventure isn\'t solely confined to sating a thirst for adrenaline and conquering the unknown; indeed, it resides in fostering compassion, fostering amicable bonds, and cherishing the beauty entwined within the quotidian veld.\n' +
|
||||
"As the weeks metamorphosed into months, Ethan grew wiser and more attuned to the ebb and flow of the world enveloping him. He gleaned that true adventure isn't solely confined to sating a thirst for adrenaline and conquering the unknown; indeed, it resides in fostering compassion, fostering amicable bonds, and cherishing the beauty entwined within the quotidian veld.\n" +
|
||||
'\n' +
|
||||
'Eventually, Ethan\'s quest drew him homeward, back to his village. Buoying the Eye of Imagination\'s ethereal power, he imbued the hitherto unremarkable settlement with the patina of infinite possibilities. The bounteous fields bloomed into kaleidoscopic gardens, simple instruments transmuting into intricate masterpieces, and the villagers themselves clasped within their hearts a renewed ardor, a conflagration of hope and inspiration.\n' +
|
||||
"Eventually, Ethan's quest drew him homeward, back to his village. Buoying the Eye of Imagination's ethereal power, he imbued the hitherto unremarkable settlement with the patina of infinite possibilities. The bounteous fields bloomed into kaleidoscopic gardens, simple instruments transmuting into intricate masterpieces, and the villagers themselves clasped within their hearts a renewed ardor, a conflagration of hope and inspiration.\n" +
|
||||
'\n' +
|
||||
'Behold Ethan, at present hailed as the Village Magician – a cognomen befitting his sorcery wielded within the confines of the community he adored. His exploits may have become tethered to a geographical locale, but the flame kindled by that premier sojourn never flickered. Occasionally, beneath the veiled twilight canopy, when peering skyward toward the astral canvas bedecked with countless luminescent diamonds, he\'d allow his mind to wander, cognizant of boundless adventures yet to take flight, forever awaiting his indomitable spirit.',
|
||||
"Behold Ethan, at present hailed as the Village Magician – a cognomen befitting his sorcery wielded within the confines of the community he adored. His exploits may have become tethered to a geographical locale, but the flame kindled by that premier sojourn never flickered. Occasionally, beneath the veiled twilight canopy, when peering skyward toward the astral canvas bedecked with countless luminescent diamonds, he'd allow his mind to wander, cognizant of boundless adventures yet to take flight, forever awaiting his indomitable spirit.",
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Beautifully rewritten! It captures the essence of Cormac McCarthy\'s style, with its descriptive language and evocative imagery. The story retains the spirit of adventure and the power of imagination seen in the original. Well done! If you have any more requests or if there\'s anything else I can assist you with, feel free to let me know.',
|
||||
"Beautifully rewritten! It captures the essence of Cormac McCarthy's style, with its descriptive language and evocative imagery. The story retains the spirit of adventure and the power of imagination seen in the original. Well done! If you have any more requests or if there's anything else I can assist you with, feel free to let me know.",
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
@@ -73,19 +73,19 @@
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Certainly! Here\'s a short story reflecting Cormac McCarthy\'s style:\n' +
|
||||
"Certainly! Here's a short story reflecting Cormac McCarthy's style:\n" +
|
||||
'\n' +
|
||||
'In a desolate wasteland scarred by the sins of mankind, a lone traveler ventured forth, his name forgotten by time, etched upon the shadows of memory. He navigated the barren plains, where the sun beat down, merciless and unrelenting, scorching the parched earth beneath his weary boots.\n' +
|
||||
'\n' +
|
||||
'Desolation was his only companion, the forsaken landscape a testament to the darkness that had befallen this forsaken realm. The wind whispered secrets long lost, carrying with it the echoes of a civilization long since undone, swallowed by the abyss.\n' +
|
||||
'\n' +
|
||||
'Amongst the shattered remnants of fallen cities, he sought solace, a glimmer of hope amidst the desolation. Rusted remnants of steel and concrete stood as sentinels of mankind\'s hubris, their jagged edges a chilling reminder of the fragility of all that was once deemed invincible.\n' +
|
||||
"Amongst the shattered remnants of fallen cities, he sought solace, a glimmer of hope amidst the desolation. Rusted remnants of steel and concrete stood as sentinels of mankind's hubris, their jagged edges a chilling reminder of the fragility of all that was once deemed invincible.\n" +
|
||||
'\n' +
|
||||
'As twilight descended, casting a pallid hue upon the lifeless land, he happened upon a dilapidated house, its walls crumbling under the weight of time. Drawn to the desolation, he pushed open the creaking door, its rusty hinges screaming in protest.\n' +
|
||||
'\n' +
|
||||
'Inside, a world frozen in time awaited him. Dust particles danced through the air, illuminated by slivers of light that pierced the broken windows. Here, the past lay trapped, a melancholic tableau of lives lost and dreams shattered.\n' +
|
||||
'\n' +
|
||||
'He wandered through the empty rooms, bare walls whispering stories untold. In the corner, a dilapidated piano stood, its ivory keys long since abandoned by a musician\'s touch. He approached it cautiously, a faint glimmer of anticipation igniting in his weather-worn eyes.\n' +
|
||||
"He wandered through the empty rooms, bare walls whispering stories untold. In the corner, a dilapidated piano stood, its ivory keys long since abandoned by a musician's touch. He approached it cautiously, a faint glimmer of anticipation igniting in his weather-worn eyes.\n" +
|
||||
'\n' +
|
||||
'Determined, he carefully brushed away the dust and debris that cloaked the instrument. With trembling hands, he pressed a key, and a discordant note filled the air, mingling with the sighs of forgotten melodies. The sound echoed through the silence, bringing with it the remnants of forgotten symphonies.\n' +
|
||||
'\n' +
|
||||
@@ -103,13 +103,13 @@
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Thank you! I\'m glad you enjoyed it. If you have any more writing requests or if there\'s anything else I can assist you with, feel free to let me know.',
|
||||
"Thank you! I'm glad you enjoyed it. If you have any more writing requests or if there's anything else I can assist you with, feel free to let me know.",
|
||||
},
|
||||
{ role: 'user', content: 'you are very helpful' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Thank you for your kind words! I\'m here to assist you in any way I can. If you have any more questions, need further assistance, or just want to chat, feel free to reach out.',
|
||||
"Thank you for your kind words! I'm here to assist you in any way I can. If you have any more questions, need further assistance, or just want to chat, feel free to reach out.",
|
||||
},
|
||||
{ role: 'user', content: 'no you man' },
|
||||
];
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('addImages', () => {
|
||||
|
||||
it('should append correctly from a real scenario', () => {
|
||||
responseMessage.text =
|
||||
'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?';
|
||||
"Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
|
||||
const originalText = responseMessage.text;
|
||||
const imageMarkdown = '';
|
||||
intermediateSteps.push({ observation: imageMarkdown });
|
||||
|
||||
@@ -65,14 +65,14 @@ function buildPromptPrefix({ result, message, functionsAgent }) {
|
||||
const preliminaryAnswer =
|
||||
result.output?.length > 0 ? `Preliminary Answer: "${result.output.trim()}"` : '';
|
||||
const prefix = preliminaryAnswer
|
||||
? 'review and improve the answer you generated using plugins in response to the User Message below. The user hasn\'t seen your answer or thoughts yet.'
|
||||
? "review and improve the answer you generated using plugins in response to the User Message below. The user hasn't seen your answer or thoughts yet."
|
||||
: 'respond to the User Message below based on your preliminary thoughts & actions.';
|
||||
|
||||
return `As a helpful AI Assistant, ${prefix}${errorMessage}\n${internalActions}
|
||||
${preliminaryAnswer}
|
||||
Reply conversationally to the User based on your ${
|
||||
preliminaryAnswer ? 'preliminary answer, ' : ''
|
||||
}internal actions, thoughts, and observations, making improvements wherever possible, but do not modify URLs.
|
||||
preliminaryAnswer ? 'preliminary answer, ' : ''
|
||||
}internal actions, thoughts, and observations, making improvements wherever possible, but do not modify URLs.
|
||||
${
|
||||
preliminaryAnswer
|
||||
? ''
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('addCacheControl', () => {
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'I\'m doing well, thanks!' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: "I'm doing well, thanks!" }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Great!' }] },
|
||||
];
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('addCacheControl', () => {
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: 'How are you?' },
|
||||
{ role: 'assistant', content: 'I\'m doing well, thanks!' },
|
||||
{ role: 'assistant', content: "I'm doing well, thanks!" },
|
||||
{ role: 'user', content: 'Great!' },
|
||||
];
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('addCacheControl', () => {
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
|
||||
{ role: 'assistant', content: 'I\'m doing well, thanks!' },
|
||||
{ role: 'assistant', content: "I'm doing well, thanks!" },
|
||||
{ role: 'user', content: 'Great!' },
|
||||
];
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('addCacheControl', () => {
|
||||
},
|
||||
]);
|
||||
expect(result[1].content).toBe('Hi there');
|
||||
expect(result[3].content).toBe('I\'m doing well, thanks!');
|
||||
expect(result[3].content).toBe("I'm doing well, thanks!");
|
||||
});
|
||||
|
||||
test('should handle edge case with multiple content types', () => {
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
|
||||
content: [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: 'I\'ll search for that information.',
|
||||
[ContentTypes.TEXT]: "I'll search for that information.",
|
||||
tool_call_ids: ['search_1'],
|
||||
},
|
||||
{
|
||||
@@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
|
||||
},
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.',
|
||||
[ContentTypes.TEXT]: "Now, I'll convert the temperature.",
|
||||
tool_call_ids: ['convert_1'],
|
||||
},
|
||||
{
|
||||
@@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
|
||||
output: '23.89°C',
|
||||
},
|
||||
},
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -171,7 +171,7 @@ describe('formatAgentMessages', () => {
|
||||
expect(result[4]).toBeInstanceOf(AIMessage);
|
||||
|
||||
// Check first AIMessage
|
||||
expect(result[0].content).toBe('I\'ll search for that information.');
|
||||
expect(result[0].content).toBe("I'll search for that information.");
|
||||
expect(result[0].tool_calls).toHaveLength(1);
|
||||
expect(result[0].tool_calls[0]).toEqual({
|
||||
id: 'search_1',
|
||||
@@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
|
||||
);
|
||||
|
||||
// Check second AIMessage
|
||||
expect(result[2].content).toBe('Now, I\'ll convert the temperature.');
|
||||
expect(result[2].content).toBe("Now, I'll convert the temperature.");
|
||||
expect(result[2].tool_calls).toHaveLength(1);
|
||||
expect(result[2].tool_calls[0]).toEqual({
|
||||
id: 'convert_1',
|
||||
@@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
|
||||
|
||||
// Check final AIMessage
|
||||
expect(result[4].content).toStrictEqual([
|
||||
{ [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT },
|
||||
{ [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('formatAgentMessages', () => {
|
||||
role: 'assistant',
|
||||
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
|
||||
},
|
||||
{ role: 'user', content: 'What\'s the weather?' },
|
||||
{ role: 'user', content: "What's the weather?" },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
@@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -265,12 +265,12 @@ describe('formatAgentMessages', () => {
|
||||
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
|
||||
]);
|
||||
expect(result[2].content).toStrictEqual([
|
||||
{ [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT },
|
||||
{ [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
|
||||
]);
|
||||
expect(result[3].content).toBe('Let me check that for you.');
|
||||
expect(result[4].content).toBe('Sunny, 75°F');
|
||||
expect(result[5].content).toStrictEqual([
|
||||
{ [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT },
|
||||
{ [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
|
||||
]);
|
||||
|
||||
// Check that there are no consecutive AIMessages
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module.exports = {
|
||||
instructions:
|
||||
'Remember, all your responses MUST be in the format described. Do not respond unless it\'s in the format described, using the structure of Action, Action Input, etc.',
|
||||
"Remember, all your responses MUST be in the format described. Do not respond unless it's in the format described, using the structure of Action, Action Input, etc.",
|
||||
errorInstructions:
|
||||
'\nYou encountered an error in attempting a response. The user is not aware of the error so you shouldn\'t mention it.\nReview the actions taken carefully in case there is a partial or complete answer within them.\nError Message:',
|
||||
"\nYou encountered an error in attempting a response. The user is not aware of the error so you shouldn't mention it.\nReview the actions taken carefully in case there is a partial or complete answer within them.\nError Message:",
|
||||
imageInstructions:
|
||||
'You must include the exact image paths from above, formatted in Markdown syntax: ',
|
||||
completionInstructions:
|
||||
|
||||
@@ -18,17 +18,17 @@ function generateShadcnPrompt(options) {
|
||||
Here are the components that are available, along with how to import them, and how to use them:
|
||||
|
||||
${Object.values(components)
|
||||
.map((component) => {
|
||||
if (useXML) {
|
||||
return dedent`
|
||||
.map((component) => {
|
||||
if (useXML) {
|
||||
return dedent`
|
||||
<component>
|
||||
<name>${component.componentName}</name>
|
||||
<import-instructions>${component.importDocs}</import-instructions>
|
||||
<usage-instructions>${component.usageDocs}</usage-instructions>
|
||||
</component>
|
||||
`;
|
||||
} else {
|
||||
return dedent`
|
||||
} else {
|
||||
return dedent`
|
||||
# ${component.componentName}
|
||||
|
||||
## Import Instructions
|
||||
@@ -37,9 +37,9 @@ function generateShadcnPrompt(options) {
|
||||
## Usage Instructions
|
||||
${component.usageDocs}
|
||||
`;
|
||||
}
|
||||
})
|
||||
.join('\n\n')}
|
||||
}
|
||||
})
|
||||
.join('\n\n')}
|
||||
`;
|
||||
|
||||
return systemPrompt;
|
||||
|
||||
@@ -5,7 +5,6 @@ const FormData = require('form-data');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logAxiosError, oaiToolkit } = require('@librechat/api');
|
||||
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -349,7 +348,16 @@ Error Message: ${error.message}`);
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
try {
|
||||
const url = new URL(process.env.PROXY);
|
||||
axiosConfig.proxy = {
|
||||
host: url.hostname.replace(/^\[|\]$/g, ''),
|
||||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||||
protocol: url.protocol.replace(':', ''),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error parsing proxy URL:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
|
||||
|
||||
@@ -232,7 +232,7 @@ class OpenWeather extends Tool {
|
||||
|
||||
if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
|
||||
if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
|
||||
return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').';
|
||||
return "Error: lat and lon are required and must be numbers for this action (or specify 'city').";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ class OpenWeather extends Tool {
|
||||
let dt;
|
||||
if (action === 'timestamp') {
|
||||
if (!date) {
|
||||
return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.';
|
||||
return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required.";
|
||||
}
|
||||
dt = this.convertDateToUnix(date);
|
||||
}
|
||||
|
||||
@@ -448,7 +448,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
}
|
||||
if (!availableTools) {
|
||||
try {
|
||||
availableTools = await getMCPServerTools(safeUser.id, serverName);
|
||||
availableTools = await getMCPServerTools(serverName);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
|
||||
}
|
||||
|
||||
@@ -62,38 +62,25 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {string} params.spec
|
||||
* @param {string} params.agent_id
|
||||
* @param {string} params.endpoint
|
||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||
*/
|
||||
const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
|
||||
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
||||
const { model, ...model_parameters } = _m;
|
||||
const modelSpecs = req.config?.modelSpecs?.list;
|
||||
/** @type {TModelSpec | null} */
|
||||
let modelSpec = null;
|
||||
if (spec != null && spec !== '') {
|
||||
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
|
||||
}
|
||||
/** @type {TEphemeralAgent | null} */
|
||||
const ephemeralAgent = req.body.ephemeralAgent;
|
||||
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||
const userId = req.user?.id; // note: userId cannot be undefined at runtime
|
||||
if (modelSpec?.mcpServers) {
|
||||
for (const mcpServer of modelSpec.mcpServers) {
|
||||
mcpServers.add(mcpServer);
|
||||
}
|
||||
}
|
||||
/** @type {string[]} */
|
||||
const tools = [];
|
||||
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
|
||||
if (ephemeralAgent?.execute_code === true) {
|
||||
tools.push(Tools.execute_code);
|
||||
}
|
||||
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
|
||||
if (ephemeralAgent?.file_search === true) {
|
||||
tools.push(Tools.file_search);
|
||||
}
|
||||
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
|
||||
if (ephemeralAgent?.web_search === true) {
|
||||
tools.push(Tools.web_search);
|
||||
}
|
||||
|
||||
@@ -103,7 +90,7 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
if (addedServers.has(mcpServer)) {
|
||||
continue;
|
||||
}
|
||||
const serverTools = await getMCPServerTools(userId, mcpServer);
|
||||
const serverTools = await getMCPServerTools(mcpServer);
|
||||
if (!serverTools) {
|
||||
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
|
||||
addedServers.add(mcpServer);
|
||||
@@ -135,18 +122,17 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {string} params.spec
|
||||
* @param {string} params.agent_id
|
||||
* @param {string} params.endpoint
|
||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||
*/
|
||||
const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
|
||||
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
if (!agent_id) {
|
||||
return null;
|
||||
}
|
||||
if (agent_id === EPHEMERAL_AGENT_ID) {
|
||||
return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
|
||||
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
|
||||
}
|
||||
const agent = await getAgent({
|
||||
id: agent_id,
|
||||
|
||||
@@ -1931,7 +1931,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return tools for each server
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
return { tool1_mcp_server1: {} };
|
||||
} else if (server === 'server2') {
|
||||
@@ -2125,7 +2125,7 @@ describe('models/Agent', () => {
|
||||
getCachedTools.mockResolvedValue(availableTools);
|
||||
|
||||
// Mock getMCPServerTools to return all tools for server1
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
return availableTools; // All 100 tools belong to server1
|
||||
}
|
||||
@@ -2674,7 +2674,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return only tools matching the server
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
// Only return tool that correctly matches server1 format
|
||||
return { tool_mcp_server1: {} };
|
||||
|
||||
@@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => {
|
||||
return await Conversation.findOne({ user, conversationId }).lean();
|
||||
} catch (error) {
|
||||
logger.error('[getConvo] Error getting single conversation', error);
|
||||
return { message: 'Error getting single conversation' };
|
||||
throw new Error('Error getting single conversation');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,13 +151,21 @@ module.exports = {
|
||||
const result = await Conversation.bulkWrite(bulkOps);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[saveBulkConversations] Error saving conversations in bulk', error);
|
||||
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
|
||||
throw new Error('Failed to save conversations in bulk.');
|
||||
}
|
||||
},
|
||||
getConvosByCursor: async (
|
||||
user,
|
||||
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
||||
{
|
||||
cursor,
|
||||
limit = 25,
|
||||
isArchived = false,
|
||||
tags,
|
||||
search,
|
||||
sortBy = 'createdAt',
|
||||
sortDirection = 'desc',
|
||||
} = {},
|
||||
) => {
|
||||
const filters = [{ user }];
|
||||
if (isArchived) {
|
||||
@@ -184,35 +192,77 @@ module.exports = {
|
||||
filters.push({ conversationId: { $in: matchingIds } });
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||
return { message: 'Error during meiliSearch' };
|
||||
throw new Error('Error during meiliSearch');
|
||||
}
|
||||
}
|
||||
|
||||
const validSortFields = ['title', 'createdAt', 'updatedAt'];
|
||||
if (!validSortFields.includes(sortBy)) {
|
||||
throw new Error(
|
||||
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const finalSortBy = sortBy;
|
||||
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
let cursorFilter = null;
|
||||
if (cursor) {
|
||||
filters.push({ updatedAt: { $lt: new Date(cursor) } });
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||
const { primary, secondary } = decoded;
|
||||
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
|
||||
const secondaryValue = new Date(secondary);
|
||||
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
|
||||
|
||||
cursorFilter = {
|
||||
$or: [
|
||||
{ [finalSortBy]: { [op]: primaryValue } },
|
||||
{
|
||||
[finalSortBy]: primaryValue,
|
||||
updatedAt: { [op]: secondaryValue },
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
||||
}
|
||||
if (cursorFilter) {
|
||||
filters.push(cursorFilter);
|
||||
}
|
||||
}
|
||||
|
||||
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||
|
||||
try {
|
||||
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
|
||||
const sortObj = { [finalSortBy]: sortOrder };
|
||||
|
||||
if (finalSortBy !== 'updatedAt') {
|
||||
sortObj.updatedAt = sortOrder;
|
||||
}
|
||||
|
||||
const convos = await Conversation.find(query)
|
||||
.select(
|
||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||
)
|
||||
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
||||
.sort(sortObj)
|
||||
.limit(limit + 1)
|
||||
.lean();
|
||||
|
||||
let nextCursor = null;
|
||||
if (convos.length > limit) {
|
||||
const lastConvo = convos.pop();
|
||||
nextCursor = lastConvo.updatedAt.toISOString();
|
||||
const primaryValue = lastConvo[finalSortBy];
|
||||
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
|
||||
const secondaryStr = lastConvo.updatedAt.toISOString();
|
||||
const composite = { primary: primaryStr, secondary: secondaryStr };
|
||||
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
|
||||
}
|
||||
|
||||
return { conversations: convos, nextCursor };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||
return { message: 'Error getting conversations' };
|
||||
throw new Error('Error getting conversations');
|
||||
}
|
||||
},
|
||||
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||
@@ -252,7 +302,7 @@ module.exports = {
|
||||
return { conversations: limited, nextCursor, convoMap };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||
return { message: 'Error fetching conversations' };
|
||||
throw new Error('Error fetching conversations');
|
||||
}
|
||||
},
|
||||
getConvo,
|
||||
@@ -269,7 +319,7 @@ module.exports = {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getConvoTitle] Error getting conversation title', error);
|
||||
return { message: 'Error getting conversation title' };
|
||||
throw new Error('Error getting conversation title');
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.90",
|
||||
"@librechat/agents": "^2.4.86",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
||||
@@ -8,7 +8,6 @@ const {
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
logAxiosError,
|
||||
sanitizeTitle,
|
||||
resolveHeaders,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
@@ -776,7 +775,6 @@ class AgentClient extends BaseClient {
|
||||
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
|
||||
|
||||
config = {
|
||||
runName: 'AgentRun',
|
||||
configurable: {
|
||||
thread_id: this.conversationId,
|
||||
last_agent_index: this.agentConfigs?.size ?? 0,
|
||||
@@ -1235,10 +1233,6 @@ class AgentClient extends BaseClient {
|
||||
handleLLMEnd,
|
||||
},
|
||||
],
|
||||
configurable: {
|
||||
thread_id: this.conversationId,
|
||||
user_id: this.user ?? this.options.req.user?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1276,7 +1270,7 @@ class AgentClient extends BaseClient {
|
||||
);
|
||||
});
|
||||
|
||||
return sanitizeTitle(titleResult.title);
|
||||
return titleResult.title;
|
||||
} catch (err) {
|
||||
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
|
||||
return;
|
||||
|
||||
@@ -10,10 +10,6 @@ jest.mock('@librechat/agents', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
}));
|
||||
|
||||
describe('AgentClient - titleConvo', () => {
|
||||
let client;
|
||||
let mockRun;
|
||||
@@ -256,38 +252,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
expect(result).toBe('Generated Title');
|
||||
});
|
||||
|
||||
it('should sanitize the generated title by removing think blocks', async () => {
|
||||
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
|
||||
mockRun.generateTitle.mockResolvedValue({
|
||||
title: titleWithThinkBlock,
|
||||
});
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
// Should remove the <think> block and return only the clean title
|
||||
expect(result).toBe('User Hi Greeting');
|
||||
expect(result).not.toContain('<think>');
|
||||
expect(result).not.toContain('</think>');
|
||||
});
|
||||
|
||||
it('should return fallback title when sanitization results in empty string', async () => {
|
||||
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
|
||||
mockRun.generateTitle.mockResolvedValue({
|
||||
title: titleOnlyThinkBlock,
|
||||
});
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
// Should return the fallback title since sanitization would result in empty string
|
||||
expect(result).toBe('Untitled Conversation');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return undefined', async () => {
|
||||
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const getMCPTools = async (req, res) => {
|
||||
const mcpServers = {};
|
||||
|
||||
const cachePromises = configuredServers.map((serverName) =>
|
||||
getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })),
|
||||
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
|
||||
);
|
||||
const cacheResults = await Promise.all(cachePromises);
|
||||
|
||||
@@ -52,7 +52,7 @@ const getMCPTools = async (req, res) => {
|
||||
|
||||
if (Object.keys(serverTools).length > 0) {
|
||||
// Cache asynchronously without blocking
|
||||
cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) =>
|
||||
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
|
||||
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ afterEach(() => {
|
||||
|
||||
//TODO: This works/passes locally but http request tests fail with 404 in CI. Need to figure out why.
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('GET /', () => {
|
||||
it('should return 200 and the correct body', async () => {
|
||||
process.env.APP_TITLE = 'Test Title';
|
||||
|
||||
@@ -47,7 +47,6 @@ jest.mock('~/models', () => ({
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
setCachedTools: jest.fn(),
|
||||
getCachedTools: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
loadCustomConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ router.get('/', async (req, res) => {
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
||||
const order = req.query.order || 'desc';
|
||||
const sortBy = req.query.sortBy || 'createdAt';
|
||||
const sortDirection = req.query.sortDirection || 'desc';
|
||||
|
||||
let tags;
|
||||
if (req.query.tags) {
|
||||
@@ -44,7 +45,8 @@ router.get('/', async (req, res) => {
|
||||
isArchived,
|
||||
tags,
|
||||
search,
|
||||
order,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -205,7 +205,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
|
||||
const tools = await userConnection.fetchTools();
|
||||
await updateMCPServerTools({
|
||||
userId: flowState.userId,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
const { ToolCacheKeys } = require('../getCachedTools');
|
||||
|
||||
describe('getCachedTools - Cache Isolation Security', () => {
|
||||
describe('ToolCacheKeys.MCP_SERVER', () => {
|
||||
it('should generate cache keys that include userId', () => {
|
||||
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
|
||||
expect(key).toBe('tools:mcp:user123:github');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,25 +7,24 @@ const getLogStores = require('~/cache/getLogStores');
|
||||
const ToolCacheKeys = {
|
||||
/** Global tools available to all users */
|
||||
GLOBAL: 'tools:global',
|
||||
/** MCP tools cached by user ID and server name */
|
||||
MCP_SERVER: (userId, serverName) => `tools:mcp:${userId}:${serverName}`,
|
||||
/** MCP tools cached by server name */
|
||||
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves available tools from cache
|
||||
* @function getCachedTools
|
||||
* @param {Object} options - Options for retrieving tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to get cached tools for
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
||||
*/
|
||||
async function getCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName } = options;
|
||||
const { serverName } = options;
|
||||
|
||||
// Return MCP server-specific tools if requested
|
||||
if (serverName && userId) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
if (serverName) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
}
|
||||
|
||||
// Default to global tools
|
||||
@@ -37,18 +36,17 @@ async function getCachedTools(options = {}) {
|
||||
* @function setCachedTools
|
||||
* @param {Object} tools - The tools object to cache
|
||||
* @param {Object} options - Options for caching tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name for server-specific tools
|
||||
* @param {number} [options.ttl] - Time to live in milliseconds
|
||||
* @returns {Promise<boolean>} Whether the operation was successful
|
||||
*/
|
||||
async function setCachedTools(tools, options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName, ttl } = options;
|
||||
const { serverName, ttl } = options;
|
||||
|
||||
// Cache by MCP server if specified (requires userId)
|
||||
if (serverName && userId) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(userId, serverName), tools, ttl);
|
||||
// Cache by MCP server if specified
|
||||
if (serverName) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
|
||||
}
|
||||
|
||||
// Default to global cache
|
||||
@@ -59,14 +57,13 @@ async function setCachedTools(tools, options = {}) {
|
||||
* Invalidates cached tools
|
||||
* @function invalidateCachedTools
|
||||
* @param {Object} options - Options for invalidating tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to invalidate
|
||||
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function invalidateCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName, invalidateGlobal = false } = options;
|
||||
const { serverName, invalidateGlobal = false } = options;
|
||||
|
||||
const keysToDelete = [];
|
||||
|
||||
@@ -74,23 +71,22 @@ async function invalidateCachedTools(options = {}) {
|
||||
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
||||
}
|
||||
|
||||
if (serverName && userId) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
if (serverName) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
}
|
||||
|
||||
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP tools for a specific server from cache
|
||||
* Gets MCP tools for a specific server from cache or merges with global tools
|
||||
* @function getMCPServerTools
|
||||
* @param {string} userId - The user ID
|
||||
* @param {string} serverName - The MCP server name
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
|
||||
*/
|
||||
async function getMCPServerTools(userId, serverName) {
|
||||
async function getMCPServerTools(serverName) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
|
||||
if (serverTools) {
|
||||
return serverTools;
|
||||
|
||||
@@ -57,7 +57,7 @@ async function loadConfigModels(req) {
|
||||
|
||||
for (let i = 0; i < customEndpoints.length; i++) {
|
||||
const endpoint = customEndpoints[i];
|
||||
const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint;
|
||||
const { models, name: configName, baseURL, apiKey } = endpoint;
|
||||
const name = normalizeEndpointName(configName);
|
||||
endpointsMap[name] = endpoint;
|
||||
|
||||
@@ -76,8 +76,6 @@ async function loadConfigModels(req) {
|
||||
apiKey: API_KEY,
|
||||
baseURL: BASE_URL,
|
||||
user: req.user.id,
|
||||
userObject: req.user,
|
||||
headers: endpointHeaders,
|
||||
direct: endpoint.directEndpoint,
|
||||
userIdQuery: models.userIdQuery,
|
||||
});
|
||||
|
||||
@@ -6,12 +6,11 @@ const { getLogStores } = require('~/cache');
|
||||
/**
|
||||
* Updates MCP tools in the cache for a specific server
|
||||
* @param {Object} params - Parameters for updating MCP tools
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName - MCP server name
|
||||
* @param {Array} params.tools - Array of tool objects from MCP server
|
||||
* @returns {Promise<LCAvailableTools>}
|
||||
*/
|
||||
async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
async function updateMCPServerTools({ serverName, tools }) {
|
||||
try {
|
||||
const serverTools = {};
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
@@ -28,16 +27,14 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
};
|
||||
}
|
||||
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(
|
||||
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
|
||||
);
|
||||
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
|
||||
return serverTools;
|
||||
} catch (error) {
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, error);
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -68,22 +65,21 @@ async function mergeAppTools(appTools) {
|
||||
/**
|
||||
* Caches MCP server tools (no longer merges with global)
|
||||
* @param {object} params
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName
|
||||
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cacheMCPServerTools({ userId, serverName, serverTools }) {
|
||||
async function cacheMCPServerTools({ serverName, serverTools }) {
|
||||
try {
|
||||
const count = Object.keys(serverTools).length;
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
// Only cache server-specific tools, no merging with global
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`);
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error);
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,10 +134,10 @@ const initializeAgent = async ({
|
||||
});
|
||||
|
||||
const tokensModel =
|
||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
|
||||
const maxOutputTokens = optionalChainWithEmptyCheck(
|
||||
options.llmConfig?.maxOutputTokens,
|
||||
options.llmConfig?.maxTokens,
|
||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
|
||||
const maxTokens = optionalChainWithEmptyCheck(
|
||||
modelOptions.maxOutputTokens,
|
||||
modelOptions.maxTokens,
|
||||
0,
|
||||
);
|
||||
const agentMaxContextTokens = optionalChainWithEmptyCheck(
|
||||
@@ -203,7 +203,7 @@ const initializeAgent = async ({
|
||||
userMCPAuthMap,
|
||||
toolContextMap,
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
|
||||
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
|
||||
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
|
||||
const agentPromise = loadAgent({
|
||||
req,
|
||||
spec,
|
||||
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
@@ -21,6 +20,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
endpoint,
|
||||
agent_id,
|
||||
endpointType,
|
||||
instructions,
|
||||
model_parameters,
|
||||
agent: agentPromise,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
|
||||
const buildOptions = async (endpoint, parsedBody) => {
|
||||
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
|
||||
parsedBody;
|
||||
const endpointOption = removeNullishValues({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const {
|
||||
resolveHeaders,
|
||||
isUserProvided,
|
||||
@@ -142,27 +143,39 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
|
||||
if (optionsOnly) {
|
||||
const modelOptions = endpointOption?.model_parameters ?? {};
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
modelOptions,
|
||||
},
|
||||
clientOptions,
|
||||
);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
if (options != null) {
|
||||
options.useLegacyContent = true;
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
if (!clientOptions.streamRate) {
|
||||
if (endpoint !== Providers.OLLAMA) {
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
modelOptions,
|
||||
},
|
||||
clientOptions,
|
||||
);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
if (options != null) {
|
||||
options.useLegacyContent = true;
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
if (!clientOptions.streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
return options;
|
||||
|
||||
if (clientOptions.reverseProxyUrl) {
|
||||
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0];
|
||||
delete clientOptions.reverseProxyUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
useLegacyContent: true,
|
||||
llmConfig: modelOptions,
|
||||
};
|
||||
}
|
||||
|
||||
const client = new OpenAIClient(apiKey, clientOptions);
|
||||
|
||||
@@ -143,7 +143,7 @@ const initializeClient = async ({
|
||||
modelOptions.model = modelName;
|
||||
clientOptions = Object.assign({ modelOptions }, clientOptions);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
const options = getOpenAIConfig(apiKey, clientOptions);
|
||||
if (options != null && serverless === true) {
|
||||
options.useLegacyContent = true;
|
||||
}
|
||||
|
||||
@@ -42,26 +42,18 @@ async function getCustomConfigSpeech(req, res) {
|
||||
settings.advancedMode = speechTab.advancedMode;
|
||||
}
|
||||
|
||||
if (speechTab.speechToText !== undefined) {
|
||||
if (typeof speechTab.speechToText === 'boolean') {
|
||||
settings.speechToText = speechTab.speechToText;
|
||||
} else {
|
||||
for (const key in speechTab.speechToText) {
|
||||
if (speechTab.speechToText[key] !== undefined) {
|
||||
settings[key] = speechTab.speechToText[key];
|
||||
}
|
||||
if (speechTab.speechToText) {
|
||||
for (const key in speechTab.speechToText) {
|
||||
if (speechTab.speechToText[key] !== undefined) {
|
||||
settings[key] = speechTab.speechToText[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (speechTab.textToSpeech !== undefined) {
|
||||
if (typeof speechTab.textToSpeech === 'boolean') {
|
||||
settings.textToSpeech = speechTab.textToSpeech;
|
||||
} else {
|
||||
for (const key in speechTab.textToSpeech) {
|
||||
if (speechTab.textToSpeech[key] !== undefined) {
|
||||
settings[key] = speechTab.textToSpeech[key];
|
||||
}
|
||||
if (speechTab.textToSpeech) {
|
||||
for (const key in speechTab.textToSpeech) {
|
||||
if (speechTab.textToSpeech[key] !== undefined) {
|
||||
settings[key] = speechTab.textToSpeech[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
|
||||
|
||||
/**
|
||||
* Get current user's Entra ID group memberships from Microsoft Graph
|
||||
* Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
|
||||
* Uses /me/memberOf endpoint to get groups the user is a member of
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
||||
@@ -167,12 +167,10 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
|
||||
const getUserEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const response = await graphClient
|
||||
.api('/me/getMemberGroups')
|
||||
.post({ securityEnabledOnly: false });
|
||||
|
||||
const groupIds = Array.isArray(response?.value) ? response.value : [];
|
||||
return [...new Set(groupIds.map((groupId) => String(groupId)))];
|
||||
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
|
||||
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
|
||||
return [];
|
||||
@@ -189,22 +187,13 @@ const getUserEntraGroups = async (accessToken, sub) => {
|
||||
const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allGroupIds = [];
|
||||
let nextLink = '/me/ownedObjects/microsoft.graph.group';
|
||||
|
||||
while (nextLink) {
|
||||
const response = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
const groups = response?.value || [];
|
||||
allGroupIds.push(...groups.map((group) => group.id));
|
||||
const groupsResponse = await graphClient
|
||||
.api('/me/ownedObjects/microsoft.graph.group')
|
||||
.select('id')
|
||||
.get();
|
||||
|
||||
nextLink = response['@odata.nextLink']
|
||||
? response['@odata.nextLink']
|
||||
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
||||
.trim() || null
|
||||
: null;
|
||||
}
|
||||
|
||||
return allGroupIds;
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
|
||||
return [];
|
||||
@@ -222,27 +211,21 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
const getGroupMembers = async (accessToken, sub, groupId) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allMembers = new Set();
|
||||
let nextLink = `/groups/${groupId}/transitiveMembers`;
|
||||
const allMembers = [];
|
||||
let nextLink = `/groups/${groupId}/members`;
|
||||
|
||||
while (nextLink) {
|
||||
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
|
||||
const members = membersResponse?.value || [];
|
||||
members.forEach((member) => {
|
||||
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
|
||||
allMembers.add(member.id);
|
||||
}
|
||||
});
|
||||
const members = membersResponse.value || [];
|
||||
allMembers.push(...members.map((member) => member.id));
|
||||
|
||||
nextLink = membersResponse['@odata.nextLink']
|
||||
? membersResponse['@odata.nextLink']
|
||||
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
||||
.trim() || null
|
||||
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
return Array.from(allMembers);
|
||||
return allMembers;
|
||||
} catch (error) {
|
||||
logger.error('[getGroupMembers] Error fetching group members:', error);
|
||||
return [];
|
||||
|
||||
@@ -73,7 +73,6 @@ describe('GraphApiService', () => {
|
||||
header: jest.fn().mockReturnThis(),
|
||||
top: jest.fn().mockReturnThis(),
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
};
|
||||
|
||||
Client.init.mockReturnValue(mockGraphClient);
|
||||
@@ -515,33 +514,31 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
|
||||
describe('getUserEntraGroups', () => {
|
||||
it('should fetch user groups using getMemberGroups endpoint', async () => {
|
||||
it('should fetch user groups from memberOf endpoint', async () => {
|
||||
const mockGroupsResponse = {
|
||||
value: ['group-1', 'group-2'],
|
||||
value: [
|
||||
{
|
||||
id: 'group-1',
|
||||
},
|
||||
{
|
||||
id: 'group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups');
|
||||
expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false });
|
||||
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should deduplicate returned group ids', async () => {
|
||||
mockGraphClient.post.mockResolvedValue({
|
||||
value: ['group-1', 'group-2', 'group-1'],
|
||||
});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.post.mockRejectedValue(new Error('API error'));
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -553,7 +550,7 @@ describe('GraphApiService', () => {
|
||||
value: [],
|
||||
};
|
||||
|
||||
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -561,7 +558,7 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
|
||||
it('should handle missing value property', async () => {
|
||||
mockGraphClient.post.mockResolvedValue({});
|
||||
mockGraphClient.get.mockResolvedValue({});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -569,89 +566,6 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserOwnedEntraGroups', () => {
|
||||
it('should fetch owned groups with pagination support', async () => {
|
||||
const firstPage = {
|
||||
value: [
|
||||
{
|
||||
id: 'owned-group-1',
|
||||
},
|
||||
],
|
||||
'@odata.nextLink':
|
||||
'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
|
||||
};
|
||||
|
||||
const secondPage = {
|
||||
value: [
|
||||
{
|
||||
id: 'owned-group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
|
||||
|
||||
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/me/ownedObjects/microsoft.graph.group',
|
||||
);
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
|
||||
);
|
||||
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
|
||||
expect(mockGraphClient.get).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(result).toEqual(['owned-group-1', 'owned-group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupMembers', () => {
|
||||
it('should fetch transitive members and include only users', async () => {
|
||||
const firstPage = {
|
||||
value: [
|
||||
{ id: 'user-1', '@odata.type': '#microsoft.graph.user' },
|
||||
{ id: 'child-group', '@odata.type': '#microsoft.graph.group' },
|
||||
],
|
||||
'@odata.nextLink':
|
||||
'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc',
|
||||
};
|
||||
const secondPage = {
|
||||
value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
|
||||
|
||||
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers');
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/groups/group-id/transitiveMembers?$skiptoken=abc',
|
||||
);
|
||||
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
|
||||
expect(result).toEqual(['user-1', 'user-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testGraphApiAccess', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -39,8 +39,6 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
|
||||
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
|
||||
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
|
||||
* @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
|
||||
* @param {Record<string, string>} [params.headers] - Optional headers for the request.
|
||||
* @param {Partial<IUser>} [params.userObject] - Optional user object for header resolution.
|
||||
* @returns {Promise<string[]>} A promise that resolves to an array of model identifiers.
|
||||
* @async
|
||||
*/
|
||||
@@ -54,8 +52,6 @@ const fetchModels = async ({
|
||||
userIdQuery = false,
|
||||
createTokenConfig = true,
|
||||
tokenKey,
|
||||
headers,
|
||||
userObject,
|
||||
}) => {
|
||||
let models = [];
|
||||
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
|
||||
@@ -69,13 +65,7 @@ const fetchModels = async ({
|
||||
}
|
||||
|
||||
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
|
||||
try {
|
||||
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
|
||||
} catch (ollamaError) {
|
||||
const logMessage =
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
|
||||
logAxiosError({ message: logMessage, error: ollamaError });
|
||||
}
|
||||
return await OllamaClient.fetchModels(baseURL);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const axios = require('axios');
|
||||
const { logAxiosError, resolveHeaders } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, defaultModels } = require('librechat-data-provider');
|
||||
|
||||
const {
|
||||
@@ -18,8 +18,6 @@ jest.mock('@librechat/api', () => {
|
||||
processModelData: jest.fn((...args) => {
|
||||
return originalUtils.processModelData(...args);
|
||||
}),
|
||||
logAxiosError: jest.fn(),
|
||||
resolveHeaders: jest.fn((options) => options?.headers || {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -279,51 +277,12 @@ describe('fetchModels with Ollama specific logic', () => {
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: {},
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass headers and user object to Ollama fetchModels', async () => {
|
||||
const customHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer custom-token',
|
||||
};
|
||||
const userObject = {
|
||||
id: 'user789',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
resolveHeaders.mockReturnValueOnce(customHeaders);
|
||||
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.ollama.test.com',
|
||||
name: 'ollama',
|
||||
headers: customHeaders,
|
||||
userObject,
|
||||
});
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||
headers: customHeaders,
|
||||
user: userObject,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: customHeaders,
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
|
||||
axios.get.mockRejectedValueOnce(new Error('Ollama API error'));
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
|
||||
},
|
||||
});
|
||||
|
||||
it('should handle errors gracefully when fetching Ollama models fails', async () => {
|
||||
axios.get.mockRejectedValue(new Error('Network error'));
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
@@ -331,13 +290,8 @@ describe('fetchModels with Ollama specific logic', () => {
|
||||
name: 'OllamaAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
|
||||
expect(logAxiosError).toHaveBeenCalledWith({
|
||||
message:
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
|
||||
error: expect.any(Error),
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
expect(models).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return an empty array if no baseURL is provided', async () => {
|
||||
|
||||
@@ -255,7 +255,7 @@ describe('processMessages', () => {
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
|
||||
"The text you have uploaded is from the book \"Harry Potter and the Philosopher's Stone\" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -424,7 +424,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
|
||||
"The text you have uploaded is from the book \"Harry Potter and the Philosopher's Stone\" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -582,7 +582,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation【11:2†source】.',
|
||||
"This is a test ^1^ with pre-existing citation-like text. Here's a real citation【11:2†source】.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -610,7 +610,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
});
|
||||
|
||||
const expectedText =
|
||||
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation^1^.\n\n^1.^ test.txt';
|
||||
"This is a test ^1^ with pre-existing citation-like text. Here's a real citation^1^.\n\n^1.^ test.txt";
|
||||
|
||||
expect(result.text).toBe(expectedText);
|
||||
expect(result.edited).toBe(true);
|
||||
|
||||
@@ -98,7 +98,6 @@ async function reinitMCPServer({
|
||||
if (connection && !oauthRequired) {
|
||||
tools = await connection.fetchTools();
|
||||
availableTools = await updateMCPServerTools({
|
||||
userId: user.id,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
||||
@@ -357,18 +357,16 @@ async function setupOpenId() {
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
|
||||
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`,
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
|
||||
);
|
||||
return done(null, false, { message: 'Email domain not allowed' });
|
||||
}
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
findUser,
|
||||
email: email,
|
||||
email: claims.email,
|
||||
openidId: claims.sub,
|
||||
idOnTheSource: claims.oid,
|
||||
strategyName: 'openidStrategy',
|
||||
@@ -435,7 +433,7 @@ async function setupOpenId() {
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: email || '',
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
idOnTheSource: userinfo.oid,
|
||||
@@ -449,8 +447,8 @@ async function setupOpenId() {
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
user.idOnTheSource = userinfo.oid;
|
||||
if (email && email !== user.email) {
|
||||
user.email = email;
|
||||
if (userinfo.email && userinfo.email !== user.email) {
|
||||
user.email = userinfo.email;
|
||||
user.emailVerified = userinfo.email_verified || false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** v0.8.1-rc1 */
|
||||
/** v0.8.0 */
|
||||
module.exports = {
|
||||
roots: ['<rootDir>/src'],
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
12
client/src/@types/i18next.d.ts
vendored
12
client/src/@types/i18next.d.ts
vendored
@@ -1,9 +1,9 @@
|
||||
import { defaultNS, resources } from '~/locales/i18n';
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true
|
||||
}
|
||||
}
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||
enabled: !isEphemeralAgent(agent_id),
|
||||
});
|
||||
|
||||
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
|
||||
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
enabled: !isEphemeralAgent(agent_id),
|
||||
});
|
||||
|
||||
const { data: mcpData } = useMCPToolsQuery({
|
||||
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null,
|
||||
|
||||
@@ -58,7 +58,6 @@ const LabelController: React.FC<LabelControllerProps> = ({
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
|
||||
|
||||
// Performance check: rendering should be fast
|
||||
const renderTime = endTime - startTime;
|
||||
expect(renderTime).toBeLessThan(740);
|
||||
expect(renderTime).toBeLessThan(720);
|
||||
|
||||
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
|
||||
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
|
||||
|
||||
89
client/src/components/Artifacts/Thinking.tsx
Normal file
89
client/src/components/Artifacts/Thinking.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useMemo, memo, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Atom, ChevronDown } from 'lucide-react';
|
||||
import type { MouseEvent, FC } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const BUTTON_STYLES = {
|
||||
base: 'group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] animate-thinking-appear',
|
||||
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200',
|
||||
} as const;
|
||||
|
||||
const CONTENT_STYLES = {
|
||||
wrapper: 'relative pl-3 text-text-secondary',
|
||||
border:
|
||||
'absolute left-0 h-[calc(100%-10px)] border-l-2 border-border-medium dark:border-border-heavy',
|
||||
partBorder:
|
||||
'absolute left-0 h-[calc(100%)] border-l-2 border-border-medium dark:border-border-heavy',
|
||||
text: 'whitespace-pre-wrap leading-[26px]',
|
||||
} as const;
|
||||
|
||||
export const ThinkingContent: FC<{ children: React.ReactNode; isPart?: boolean }> = memo(
|
||||
({ isPart, children }) => (
|
||||
<div className={CONTENT_STYLES.wrapper}>
|
||||
<div className={isPart === true ? CONTENT_STYLES.partBorder : CONTENT_STYLES.border} />
|
||||
<p className={CONTENT_STYLES.text}>{children}</p>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
export const ThinkingButton = memo(
|
||||
({
|
||||
isExpanded,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} className={BUTTON_STYLES.base}>
|
||||
<Atom size={14} className="mr-1.5 text-text-secondary" />
|
||||
{label}
|
||||
<ChevronDown className={`${BUTTON_STYLES.icon} ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
const showThinking = useRecoilValue<boolean>(store.showThinking);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
||||
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
|
||||
</div>
|
||||
<div
|
||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ThinkingButton.displayName = 'ThinkingButton';
|
||||
ThinkingContent.displayName = 'ThinkingContent';
|
||||
Thinking.displayName = 'Thinking';
|
||||
|
||||
export default memo(Thinking);
|
||||
@@ -156,7 +156,6 @@ test('renders registration form', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('calls registerUser.mutate on registration', async () => {
|
||||
// const mutate = jest.fn();
|
||||
// const { getByTestId, getByRole, history } = setup({
|
||||
|
||||
@@ -129,11 +129,7 @@ const BookmarkForm = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid w-full items-center gap-2">
|
||||
<Label
|
||||
id="bookmark-description-label"
|
||||
htmlFor="bookmark-description"
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_bookmarks_description')}
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
@@ -151,7 +147,6 @@ const BookmarkForm = ({
|
||||
className={cn(
|
||||
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
|
||||
)}
|
||||
aria-labelledby="bookmark-description-label"
|
||||
/>
|
||||
</div>
|
||||
{conversationId != null && conversationId && (
|
||||
@@ -166,7 +161,6 @@ const BookmarkForm = ({
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field.value?.toString()}
|
||||
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState, useCallback, useMemo } from 'react';
|
||||
import React, { memo, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { CheckboxButton } from '@librechat/client';
|
||||
import { ArtifactModes } from 'librechat-data-provider';
|
||||
@@ -18,6 +18,7 @@ function Artifacts() {
|
||||
const { toggleState, debouncedChange, isPinned } = artifacts;
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [isButtonExpanded, setIsButtonExpanded] = useState(false);
|
||||
|
||||
const currentState = useMemo<ArtifactsToggleState>(() => {
|
||||
if (typeof toggleState === 'string' && toggleState) {
|
||||
@@ -33,11 +34,26 @@ function Artifacts() {
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isEnabled) {
|
||||
debouncedChange({ value: '' });
|
||||
setIsButtonExpanded(false);
|
||||
} else {
|
||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
}
|
||||
}, [isEnabled, debouncedChange]);
|
||||
|
||||
const handleMenuButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsButtonExpanded(!isButtonExpanded);
|
||||
},
|
||||
[isButtonExpanded],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopoverOpen) {
|
||||
setIsButtonExpanded(false);
|
||||
}
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const handleShadcnToggle = useCallback(() => {
|
||||
if (isShadcnEnabled) {
|
||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
@@ -77,21 +93,24 @@ function Artifacts() {
|
||||
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
||||
'transition-colors',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={handleMenuButtonClick}
|
||||
>
|
||||
<ChevronDown className="ml-1 h-4 w-4 text-text-secondary md:ml-0" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-1 h-4 w-4 text-text-secondary transition-transform duration-300 md:ml-0.5',
|
||||
isButtonExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</Ariakit.MenuButton>
|
||||
|
||||
<Ariakit.Menu
|
||||
gutter={8}
|
||||
gutter={4}
|
||||
className={cn(
|
||||
'animate-popover z-50 flex max-h-[300px]',
|
||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
||||
'border border-border-light',
|
||||
'min-w-[250px] outline-none',
|
||||
'animate-popover-top-left z-50 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary shadow-lg',
|
||||
)}
|
||||
portal
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="mb-2 text-xs font-medium text-text-secondary">
|
||||
@@ -106,18 +125,16 @@ function Artifacts() {
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isShadcnEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
@@ -130,15 +147,15 @@ function Artifacts() {
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isCustomEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
|
||||
@@ -90,8 +90,8 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
className={cn(
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
|
||||
'animate-popover-left z-50 ml-3 mt-6 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary shadow-lg',
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
@@ -107,18 +107,16 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isShadcnEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
@@ -131,15 +129,15 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isCustomEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
useTextarea,
|
||||
useAutoSave,
|
||||
useLocalize,
|
||||
useRequiresKey,
|
||||
useHandleKeyUp,
|
||||
useQueryParams,
|
||||
@@ -39,7 +38,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
useFocusChatEffect(textAreaRef);
|
||||
const localize = useLocalize();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [, setIsScrollable] = useState(false);
|
||||
@@ -222,7 +220,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
|
||||
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
|
||||
<Mention
|
||||
conversation={conversation}
|
||||
setShowMentionPopover={setShowPlusPopover}
|
||||
newConversation={generateConversation}
|
||||
textAreaRef={textAreaRef}
|
||||
@@ -233,7 +230,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
)}
|
||||
{showMentionPopover && (
|
||||
<Mention
|
||||
conversation={conversation}
|
||||
setShowMentionPopover={setShowMentionPopover}
|
||||
newConversation={newConversation}
|
||||
textAreaRef={textAreaRef}
|
||||
@@ -251,6 +247,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
)}
|
||||
>
|
||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
{/* WIP */}
|
||||
<EditBadges
|
||||
isEditingChatBadges={isEditingBadges}
|
||||
handleCancelBadges={handleCancelBadges}
|
||||
@@ -281,7 +278,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
setIsTextAreaFocused(true);
|
||||
}}
|
||||
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||
aria-label={localize('com_ui_message_input')}
|
||||
onClick={handleFocusOrClick}
|
||||
style={{ height: 44, overflowY: 'auto' }}
|
||||
className={cn(
|
||||
|
||||
@@ -224,6 +224,7 @@ const AttachFileMenu = ({
|
||||
aria-label="Attach File Options"
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
|
||||
@@ -62,28 +62,17 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
statusText = invalidText ?? localize('com_ui_upload_invalid');
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
|
||||
statusColor,
|
||||
containerClassName,
|
||||
)}
|
||||
aria-label={statusText}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" aria-hidden="true" />
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
</button>
|
||||
<label
|
||||
htmlFor={`file-upload-${id}`}
|
||||
className={cn(
|
||||
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
|
||||
statusColor,
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
<input
|
||||
id={`file-upload-${id}`}
|
||||
value=""
|
||||
@@ -91,9 +80,8 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
className={cn('hidden', className)}
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { ArrowUpDown, Database } from 'lucide-react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
OpenAIMinimalIcon,
|
||||
AzureMinimalIcon,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
import { Checkbox, OpenAIMinimalIcon, AzureMinimalIcon, useMediaQuery } from '@librechat/client';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
||||
@@ -61,16 +55,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'filename',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_name')} />;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const file = row.original;
|
||||
@@ -100,16 +85,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'updatedAt',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_date')} />;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
@@ -197,16 +173,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'bytes',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
{localize('com_ui_size')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_size')} />;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const suffix = ' MB';
|
||||
|
||||
@@ -122,11 +122,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={localize('com_files_filter_by')}
|
||||
className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}
|
||||
>
|
||||
<Button variant="outline" className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}>
|
||||
<ListFilter className="size-3.5 sm:size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
function MCPSelectContent() {
|
||||
const { conversationId, mcpServerManager } = useBadgeRowContext();
|
||||
@@ -88,7 +89,10 @@ function MCPSelectContent() {
|
||||
className="badge-icon min-w-fit"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
selectClassName={cn(
|
||||
'group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all',
|
||||
'md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
)}
|
||||
/>
|
||||
{configDialogProps && (
|
||||
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
||||
|
||||
@@ -108,10 +108,10 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
'w-full min-w-0 justify-between text-sm',
|
||||
isServerInitializing &&
|
||||
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
|
||||
isSelected && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-grow items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isSelected} />
|
||||
<span>{serverName}</span>
|
||||
</div>
|
||||
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { useCombobox } from '@librechat/client';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { MentionOption, ConvoGenerator } from '~/common';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||
@@ -15,7 +14,6 @@ import MentionItem from './MentionItem';
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
export default function Mention({
|
||||
conversation,
|
||||
setShowMentionPopover,
|
||||
newConversation,
|
||||
textAreaRef,
|
||||
@@ -23,7 +21,6 @@ export default function Mention({
|
||||
placeholder = 'com_ui_mention',
|
||||
includeAssistants = true,
|
||||
}: {
|
||||
conversation: TConversation | null;
|
||||
setShowMentionPopover: SetterOrUpdater<boolean>;
|
||||
newConversation: ConvoGenerator;
|
||||
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
@@ -45,7 +42,6 @@ export default function Mention({
|
||||
const { onSelectMention } = useSelectMention({
|
||||
presets,
|
||||
modelSpecs,
|
||||
conversation,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
newConversation,
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function StreamAudio({ index = 0 }) {
|
||||
const { pauseGlobalAudio } = usePauseGlobalAudio();
|
||||
|
||||
const { conversationId: paramId } = useParams();
|
||||
const queryParam = paramId === 'new' ? paramId : latestMessage?.conversationId ?? paramId ?? '';
|
||||
const queryParam = paramId === 'new' ? paramId : (latestMessage?.conversationId ?? paramId ?? '');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const getMessages = useCallback(
|
||||
|
||||
@@ -307,10 +307,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
aria-label="Tools Options"
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<Settings2 className="icon-md" />
|
||||
<Settings2 className="size-5" />
|
||||
</div>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
|
||||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
|
||||
interface ModelSelectorChatContextValue {
|
||||
@@ -8,7 +8,6 @@ interface ModelSelectorChatContextValue {
|
||||
spec?: string | null;
|
||||
agent_id?: string | null;
|
||||
assistant_id?: string | null;
|
||||
conversation: TConversation | null;
|
||||
newConversation: ReturnType<typeof useChatContext>['newConversation'];
|
||||
}
|
||||
|
||||
@@ -27,10 +26,16 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN
|
||||
spec: conversation?.spec,
|
||||
agent_id: conversation?.agent_id,
|
||||
assistant_id: conversation?.assistant_id,
|
||||
conversation,
|
||||
newConversation,
|
||||
}),
|
||||
[conversation, newConversation],
|
||||
[
|
||||
conversation?.endpoint,
|
||||
conversation?.model,
|
||||
conversation?.spec,
|
||||
conversation?.agent_id,
|
||||
conversation?.assistant_id,
|
||||
newConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } =
|
||||
const { endpoint, model, spec, agent_id, assistant_id, newConversation } =
|
||||
useModelSelectorChatContext();
|
||||
const modelSpecs = useMemo(() => {
|
||||
const specs = startupConfig?.modelSpecs?.list ?? [];
|
||||
@@ -96,7 +96,6 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
|
||||
// presets,
|
||||
modelSpecs,
|
||||
conversation,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
newConversation,
|
||||
|
||||
@@ -33,7 +33,7 @@ export const data: TModelSpec[] = [
|
||||
iconURL: EModelEndpoint.openAI, // Allow using project-included icons
|
||||
preset: {
|
||||
chatGptLabel: 'Vision Helper',
|
||||
greeting: 'What\'s up!!',
|
||||
greeting: "What's up!!",
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4-turbo',
|
||||
promptPrefix:
|
||||
|
||||
@@ -59,10 +59,9 @@ const PresetItems: FC<{
|
||||
</label>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
|
||||
aria-label={localize('com_ui_clear') + ' ' + localize('com_ui_all')}
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
@@ -71,12 +70,11 @@ const PresetItems: FC<{
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="mr-1 flex w-[22px] items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path>
|
||||
</svg>
|
||||
{localize('com_ui_clear')} {localize('com_ui_all')}
|
||||
</button>
|
||||
</label>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -55,7 +55,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className={cn('flex items-center gap-1 ')}>
|
||||
<div className={cn('flex items-center gap-1')}>
|
||||
{icon != null ? icon : null}
|
||||
<div className={cn('truncate', textClassName)}>
|
||||
{title}
|
||||
@@ -72,7 +72,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block "
|
||||
className="icon-md block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
@@ -6,11 +7,14 @@ import type {
|
||||
TAttachment,
|
||||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
import { EditTextPart } from './Parts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
type ContentPartsProps = {
|
||||
@@ -48,10 +52,32 @@ const ContentParts = memo(
|
||||
siblingIdx,
|
||||
setSiblingIdx,
|
||||
}: ContentPartsProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const hasReasoningParts = useMemo(() => {
|
||||
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
||||
const allThinkPartsHaveContent =
|
||||
content?.every((part) => {
|
||||
if (part?.type !== ContentTypes.THINK) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof part.think === 'string') {
|
||||
const cleanedContent = part.think.replace(/<\/?think>/g, '').trim();
|
||||
return cleanedContent.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}) ?? false;
|
||||
|
||||
return hasThinkPart && allThinkPartsHaveContent;
|
||||
}, [content]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,40 +126,57 @@ const ContentParts = memo(
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
{content.map((part, idx) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
{hasReasoningParts && (
|
||||
<div className="mb-5">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((prev) => {
|
||||
const val = !prev;
|
||||
setShowThinking(val);
|
||||
return val;
|
||||
})
|
||||
}
|
||||
label={
|
||||
effectiveIsSubmitting && isLast
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{content
|
||||
.filter((part) => part)
|
||||
.map((part, idx) => {
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const attachments = attachmentMap[toolCallId];
|
||||
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded: true,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={partAttachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -151,7 +151,7 @@ const EditMessage = ({
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div className="bg-token-main-surface-primary relative mt-2 flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
ref={(e) => {
|
||||
@@ -168,7 +168,6 @@ const EditMessage = ({
|
||||
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
|
||||
removeFocusRings,
|
||||
)}
|
||||
aria-label={localize('com_ui_message_input')}
|
||||
dir={isRTL ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,89 +4,67 @@ import { DelayedRender } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
||||
import Error from '~/components/Messages/Content/Error';
|
||||
import Thinking from '~/components/Artifacts/Thinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import EditMessage from './EditMessage';
|
||||
import Thinking from './Parts/Thinking';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.';
|
||||
const DELAYED_ERROR_TIMEOUT = 5500;
|
||||
const UNFINISHED_DELAY = 250;
|
||||
|
||||
const parseThinkingContent = (text: string) => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
};
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorBox = ({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConnectionError = ({ message }: { message?: TMessage }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<DelayedRender delay={DELAYED_ERROR_TIMEOUT}>
|
||||
<Container message={message}>
|
||||
<div className="mt-2 rounded-xl border border-red-500/20 bg-red-50/50 px-4 py-3 text-sm text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100">
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorMessage = ({
|
||||
text,
|
||||
message,
|
||||
className = '',
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & { message?: TMessage }) => {
|
||||
if (text === ERROR_CONNECTION_TEXT) {
|
||||
return <ConnectionError message={message} />;
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & {
|
||||
message?: TMessage;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
if (text === 'Error connecting to server, try refreshing the page.') {
|
||||
console.log('error message', message);
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DelayedRender delay={5500}>
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<ErrorBox className={className}>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Error text={text} />
|
||||
</ErrorBox>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -94,29 +72,27 @@ export const ErrorMessage = ({
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
|
||||
const showCursorState = useMemo(
|
||||
() => showCursor === true && isSubmitting,
|
||||
[showCursor, isSubmitting],
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!isCreatedByUser) {
|
||||
return <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
}
|
||||
if (enableUserMsgMarkdown) {
|
||||
return <MarkdownLite content={text} />;
|
||||
}
|
||||
return <>{text}</>;
|
||||
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
|
||||
let content: React.ReactElement;
|
||||
if (!isCreatedByUser) {
|
||||
content = <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
} else if (enableUserMsgMarkdown) {
|
||||
content = <MarkdownLite content={text} />;
|
||||
} else {
|
||||
content = <>{text}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
isSubmitting ? 'submitting' : '',
|
||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
||||
isSubmitting && 'submitting',
|
||||
showCursorState && text.length > 0 && 'result-streaming',
|
||||
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
||||
)}
|
||||
@@ -127,6 +103,7 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
|
||||
);
|
||||
};
|
||||
|
||||
// Unfinished Message Component
|
||||
export const UnfinishedMessage = ({ message }: { message: TMessage }) => (
|
||||
<ErrorMessage
|
||||
message={message}
|
||||
@@ -146,14 +123,21 @@ const MessageContent = ({
|
||||
const { message } = props;
|
||||
const { messageId } = message;
|
||||
|
||||
const { thinkingContent, regularContent } = useMemo(() => parseThinkingContent(text), [text]);
|
||||
const { thinkingContent, regularContent } = useMemo(() => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]);
|
||||
|
||||
const unfinishedMessage = useMemo(
|
||||
() =>
|
||||
!isSubmitting && unfinished ? (
|
||||
<Suspense>
|
||||
<DelayedRender delay={UNFINISHED_DELAY}>
|
||||
<DelayedRender delay={250}>
|
||||
<UnfinishedMessage message={message} />
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
@@ -162,10 +146,8 @@ const MessageContent = ({
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={message} text={text} />;
|
||||
}
|
||||
|
||||
if (edit) {
|
||||
return <ErrorMessage message={props.message} text={text} />;
|
||||
} else if (edit) {
|
||||
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,10 +65,6 @@ const Part = memo(
|
||||
if (part.tool_call_ids != null && !text) {
|
||||
return null;
|
||||
}
|
||||
/** Skip rendering if text is only whitespace to avoid empty Container */
|
||||
if (!isLast && text.length > 0 && /^\s*$/.test(text)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
@@ -79,7 +75,7 @@ const Part = memo(
|
||||
if (typeof reasoning !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
|
||||
return <Reasoning reasoning={reasoning} />;
|
||||
} else if (part.type === ContentTypes.TOOL_CALL) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useForm } from 'react-hook-form';
|
||||
import { TextareaAutosize } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Lightbulb, MessageSquare } from 'lucide-react';
|
||||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { TEditProps } from '~/common';
|
||||
@@ -154,22 +153,6 @@ const EditTextPart = ({
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
{part.type === ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<Lightbulb className="size-3.5" />
|
||||
{localize('com_ui_thoughts')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{part.type !== ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<MessageSquare className="size-3.5" />
|
||||
{localize('com_ui_response')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
@@ -187,7 +170,6 @@ const EditTextPart = ({
|
||||
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
|
||||
removeFocusRings,
|
||||
)}
|
||||
aria-label={localize('com_ui_editable_message')}
|
||||
dir={isRTL ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { ThinkingContent, ThinkingButton } from './Thinking';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { ThinkingContent } from '~/components/Artifacts/Thinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ReasoningProps = {
|
||||
reasoning: string;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reasoning Component (MODERN SYSTEM)
|
||||
*
|
||||
* Used for structured content parts with ContentTypes.THINK type.
|
||||
* This handles modern message format where content is an array of typed parts.
|
||||
*
|
||||
* Pattern: `{ content: [{ type: "think", think: "<think>content</think>" }, ...] }`
|
||||
*
|
||||
* Used by:
|
||||
* - ContentParts.tsx → Part.tsx for structured messages
|
||||
* - Agent/Assistant responses (OpenAI Assistants, custom agents)
|
||||
* - O-series models (o1, o3) with reasoning capabilities
|
||||
* - Modern Claude responses with thinking blocks
|
||||
*
|
||||
* Key differences from legacy Thinking.tsx:
|
||||
* - Works with content parts array instead of plain text
|
||||
* - Strips `<think>` tags instead of `:::thinking:::` markers
|
||||
* - Each THINK part has its own independent toggle button
|
||||
* - Can be interleaved with other content types
|
||||
*
|
||||
* For legacy text-based messages, see Thinking.tsx component.
|
||||
*/
|
||||
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking] = useAtom(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||
const { isExpanded, nextType } = useMessageContext();
|
||||
const reasoningText = useMemo(() => {
|
||||
return reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
@@ -49,45 +17,22 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||
.trim();
|
||||
}, [reasoning]);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const label = useMemo(
|
||||
() =>
|
||||
effectiveIsSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts'),
|
||||
[effectiveIsSubmitting, localize, isLast],
|
||||
);
|
||||
|
||||
if (!reasoningText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/reasoning">
|
||||
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={reasoningText}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-8',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useState, useMemo, memo, useCallback } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Lightbulb, ChevronDown } from 'lucide-react';
|
||||
import { Clipboard, CheckMark } from '@librechat/client';
|
||||
import type { MouseEvent, FC } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* ThinkingContent - Displays the actual thinking/reasoning content
|
||||
* Used by both legacy text-based messages and modern content parts
|
||||
*/
|
||||
export const ThinkingContent: FC<{
|
||||
children: React.ReactNode;
|
||||
}> = memo(({ children }) => {
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 text-text-secondary">
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ThinkingButton - Toggle button for expanding/collapsing thinking content
|
||||
* Shows lightbulb icon by default, chevron on hover
|
||||
* Shared between legacy Thinking component and modern ContentParts
|
||||
*/
|
||||
export const ThinkingButton = memo(
|
||||
({
|
||||
isExpanded,
|
||||
onClick,
|
||||
label,
|
||||
content,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
content?: string;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
},
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
|
||||
<Lightbulb className="icon-sm absolute text-text-secondary opacity-100 transition-opacity group-hover/button:opacity-0" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'icon-sm absolute transform-gpu text-text-primary opacity-0 transition-all duration-300 group-hover/button:opacity-100',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{content && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={
|
||||
isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_thoughts_to_clipboard')
|
||||
}
|
||||
className={cn(
|
||||
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
)}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Thinking Component (LEGACY SYSTEM)
|
||||
*
|
||||
* Used for simple text-based messages with `:::thinking:::` markers.
|
||||
* This handles the old message format where text contains embedded thinking blocks.
|
||||
*
|
||||
* Pattern: `:::thinking\n{content}\n:::\n{response}`
|
||||
*
|
||||
* Used by:
|
||||
* - MessageContent.tsx for plain text messages
|
||||
* - Legacy message format compatibility
|
||||
* - User messages when manually adding thinking content
|
||||
*
|
||||
* For modern structured content (agents/assistants), see Reasoning.tsx component.
|
||||
*/
|
||||
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
const showThinking = useAtomValue(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
||||
|
||||
// Extract text content for copy functionality
|
||||
const textContent = useMemo(() => {
|
||||
if (typeof children === 'string') {
|
||||
return children;
|
||||
}
|
||||
return '';
|
||||
}, [children]);
|
||||
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={textContent}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ThinkingButton.displayName = 'ThinkingButton';
|
||||
ThinkingContent.displayName = 'ThinkingContent';
|
||||
Thinking.displayName = 'Thinking';
|
||||
|
||||
export default memo(Thinking);
|
||||
@@ -15,7 +15,7 @@ export default function ProgressCircle({
|
||||
className="absolute left-1/2 top-1/2 h-[23px] w-[23px] -translate-x-1/2 -translate-y-1/2 text-brand-purple"
|
||||
>
|
||||
<circle
|
||||
className="origin-[50%_50%] -rotate-90 stroke-brand-purple/25 dark:stroke-brand-purple/50"
|
||||
className="stroke-brand-purple/25 dark:stroke-brand-purple/50 origin-[50%_50%] -rotate-90"
|
||||
strokeWidth="7.826086956521739"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageRender from './ui/MessageRender';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -201,6 +201,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||
overscanRowCount={10}
|
||||
className="outline-none"
|
||||
style={{ outline: 'none' }}
|
||||
role="list"
|
||||
aria-label="Conversations"
|
||||
onRowsRendered={handleRowsRendered}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||
'group relative flex h-12 w-full items-center rounded-lg md:h-9',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
role="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -82,7 +82,7 @@ export function DeleteConversationDialog({
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="w-full truncate">
|
||||
<div>
|
||||
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
|
||||
@@ -77,13 +77,7 @@ export default function ShareButton({
|
||||
<div className="relative items-center rounded-lg p-2">
|
||||
{showQR && (
|
||||
<div className="mb-4 flex flex-col items-center">
|
||||
<QRCodeSVG
|
||||
value={sharedLink}
|
||||
size={200}
|
||||
marginSize={2}
|
||||
className="rounded-2xl"
|
||||
title={localize('com_ui_share_qr_code_description')}
|
||||
/>
|
||||
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -93,7 +87,6 @@ export default function ShareButton({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_copy_link')}
|
||||
onClick={() => {
|
||||
if (isCopying) {
|
||||
return;
|
||||
|
||||
@@ -34,8 +34,6 @@ const RenameForm: React.FC<RenameFormProps> = ({
|
||||
case 'Enter':
|
||||
onSubmit(titleInput);
|
||||
break;
|
||||
case 'Tab':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,23 +50,22 @@ const RenameForm: React.FC<RenameFormProps> = ({
|
||||
value={titleInput}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSubmit(titleInput)}
|
||||
maxLength={100}
|
||||
aria-label={localize('com_ui_new_conversation_title')}
|
||||
/>
|
||||
<div className="flex gap-1" role="toolbar">
|
||||
<button
|
||||
onClick={() => onCancel()}
|
||||
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
|
||||
aria-label={localize('com_ui_cancel')}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(titleInput)}
|
||||
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
|
||||
aria-label={localize('com_ui_save')}
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { UserIcon, useAvatar } from '@librechat/client';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import type { IconProps } from '~/common';
|
||||
@@ -15,49 +15,26 @@ type UserAvatarProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default avatar component - memoized outside to prevent recreation on every render
|
||||
*/
|
||||
const DefaultAvatar = memo(() => (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
));
|
||||
|
||||
DefaultAvatar.displayName = 'DefaultAvatar';
|
||||
|
||||
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const imageLoadedRef = useRef(false);
|
||||
|
||||
const imageSrc = useMemo(() => (user?.avatar ?? '') || avatarSrc, [user?.avatar, avatarSrc]);
|
||||
|
||||
/** Reset loaded state and error state if image source changes */
|
||||
useEffect(() => {
|
||||
imageLoadedRef.current = false;
|
||||
setImageError(false);
|
||||
}, [imageSrc]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
imageLoadedRef.current = false;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
imageLoadedRef.current = true;
|
||||
setImageError(false);
|
||||
}, []);
|
||||
|
||||
const hasAvatar = useMemo(() => imageSrc !== '', [imageSrc]);
|
||||
const showImage = useMemo(() => hasAvatar && !imageError, [hasAvatar, imageError]);
|
||||
const renderDefaultAvatar = () => (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -68,14 +45,14 @@ const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAva
|
||||
}}
|
||||
className={cn('relative flex items-center justify-center', className ?? '')}
|
||||
>
|
||||
{!showImage ? (
|
||||
<DefaultAvatar />
|
||||
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) ||
|
||||
imageError ? (
|
||||
renderDefaultAvatar()
|
||||
) : (
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={imageSrc}
|
||||
src={(user?.avatar ?? '') || avatarSrc}
|
||||
alt="avatar"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
@@ -92,12 +69,8 @@ const Icon: React.FC<IconProps> = memo((props) => {
|
||||
const avatarSrc = useAvatar(user);
|
||||
const localize = useLocalize();
|
||||
|
||||
const username = useMemo(
|
||||
() => user?.name ?? user?.username ?? localize('com_nav_user'),
|
||||
[user?.name, user?.username, localize],
|
||||
);
|
||||
|
||||
if (isCreatedByUser) {
|
||||
const username = user?.name ?? user?.username ?? localize('com_nav_user');
|
||||
return (
|
||||
<UserAvatar
|
||||
size={size}
|
||||
|
||||
@@ -151,7 +151,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -161,9 +160,7 @@ export default function Settings({
|
||||
<div className="flex justify-between">
|
||||
<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: '1' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 1)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="top-p-int"
|
||||
@@ -192,7 +189,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-p-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="topp" side={ESide.Left} />
|
||||
@@ -203,9 +199,7 @@ export default function Settings({
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_frequency_penalty')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '0' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="freq-penalty-int"
|
||||
@@ -234,7 +228,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="freq-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="freq" side={ESide.Left} />
|
||||
@@ -245,9 +238,7 @@ export default function Settings({
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_presence_penalty')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '0' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="pres-penalty-int"
|
||||
@@ -276,7 +267,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="pres-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="pres" side={ESide.Left} />
|
||||
@@ -316,7 +306,6 @@ export default function Settings({
|
||||
onCheckedChange={(checked: boolean) => setResendFiles(checked)}
|
||||
disabled={readonly}
|
||||
className="flex"
|
||||
aria-label={localize('com_endpoint_plug_resend_files')}
|
||||
/>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="resend" side={ESide.Bottom} />
|
||||
</HoverCardTrigger>
|
||||
@@ -334,7 +323,6 @@ export default function Settings({
|
||||
max={2}
|
||||
min={0}
|
||||
step={1}
|
||||
aria-label={localize('com_endpoint_plug_image_detail')}
|
||||
/>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="detail" side={ESide.Bottom} />
|
||||
</HoverCardTrigger>
|
||||
|
||||
@@ -53,9 +53,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_temperature')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '0' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="temp-int"
|
||||
@@ -84,7 +82,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -104,7 +101,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
onCheckedChange={onCheckedChangeAgent}
|
||||
disabled={readonly}
|
||||
className="ml-4 mt-2"
|
||||
aria-label={localize('com_endpoint_plug_use_functions')}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="func" side={ESide.Bottom} />
|
||||
@@ -123,7 +119,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
onCheckedChange={onCheckedChangeSkip}
|
||||
disabled={readonly}
|
||||
className="ml-4 mt-2"
|
||||
aria-label={localize('com_endpoint_plug_skip_completion')}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="skip" side={ESide.Bottom} />
|
||||
|
||||
@@ -171,7 +171,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.temperature.min}
|
||||
step={google.temperature.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -212,7 +211,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.topP.min}
|
||||
step={google.topP.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-p-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
|
||||
@@ -254,7 +252,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.topK.min}
|
||||
step={google.topK.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-k-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} />
|
||||
@@ -299,7 +296,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.maxOutputTokens.min}
|
||||
step={google.maxOutputTokens.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="max-tokens-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover
|
||||
|
||||
@@ -256,7 +256,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -297,7 +296,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-p-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
|
||||
@@ -339,7 +337,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="freq-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="freq" side={ESide.Left} />
|
||||
@@ -381,7 +378,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="pres-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="pres" side={ESide.Left} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
|
||||
import ContentRender from './ContentRender';
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function MessageContent(props: TMessageProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<ContentRender {...props} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ function AccountSettings() {
|
||||
<Select.Select
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
data-testid="nav-user"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover aria-[expanded=true]:bg-surface-hover"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
@@ -40,11 +40,10 @@ function AccountSettings() {
|
||||
</div>
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
className="popover-ui w-[235px]"
|
||||
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
marginRight: '0px',
|
||||
translate: '0px',
|
||||
translate: '0 -4px',
|
||||
}}
|
||||
>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
|
||||
@@ -124,15 +124,13 @@ export default function ExportModal({
|
||||
disabled={!exportOptionsSupport}
|
||||
checked={includeOptions}
|
||||
onCheckedChange={setIncludeOptions}
|
||||
aria-labelledby="includeOptions-label"
|
||||
/>
|
||||
<label
|
||||
id="includeOptions-label"
|
||||
htmlFor="includeOptions"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
{exportOptionsSupport
|
||||
? localize('com_nav_export_include_endpoint_options')
|
||||
? localize('com_nav_enabled')
|
||||
: localize('com_nav_not_supported')}
|
||||
</label>
|
||||
</div>
|
||||
@@ -148,15 +146,13 @@ export default function ExportModal({
|
||||
disabled={!exportBranchesSupport}
|
||||
checked={exportBranches}
|
||||
onCheckedChange={setExportBranches}
|
||||
aria-labelledby="exportBranches-label"
|
||||
/>
|
||||
<label
|
||||
id="exportBranches-label"
|
||||
htmlFor="exportBranches"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
{exportBranchesSupport
|
||||
? localize('com_nav_export_all_message_branches')
|
||||
? localize('com_nav_enabled')
|
||||
: localize('com_nav_not_supported')}
|
||||
</label>
|
||||
</div>
|
||||
@@ -167,14 +163,8 @@ export default function ExportModal({
|
||||
{localize('com_nav_export_recursive_or_sequential')}
|
||||
</Label>
|
||||
<div className="flex h-[40px] w-full items-center space-x-3">
|
||||
<Checkbox
|
||||
id="recursive"
|
||||
checked={recursive}
|
||||
onCheckedChange={setRecursive}
|
||||
aria-labelledby="recursive-label"
|
||||
/>
|
||||
<Checkbox id="recursive" checked={recursive} onCheckedChange={setRecursive} />
|
||||
<label
|
||||
id="recursive-label"
|
||||
htmlFor="recursive"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import { Skeleton, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||
@@ -158,13 +158,12 @@ const Nav = memo(
|
||||
const headerButtons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
|
||||
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
|
||||
</Suspense>
|
||||
{hasAccessToBookmarks && (
|
||||
<>
|
||||
<div className="mt-1.5" />
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
|
||||
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
|
||||
</Suspense>
|
||||
</>
|
||||
@@ -229,7 +228,7 @@ const Nav = memo(
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import FontSizeSelector from './FontSizeSelector';
|
||||
import { ForkSettings } from './ForkSettings';
|
||||
import ChatDirection from './ChatDirection';
|
||||
@@ -29,7 +28,7 @@ const toggleSwitchConfigs = [
|
||||
key: 'centerFormOnLanding',
|
||||
},
|
||||
{
|
||||
stateAtom: showThinkingAtom,
|
||||
stateAtom: store.showThinking,
|
||||
localizationKey: 'com_nav_show_thinking',
|
||||
switchId: 'showThinking',
|
||||
hoverCardText: undefined,
|
||||
|
||||
@@ -30,7 +30,6 @@ export default function SaveBadgesState({
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="saveBadgesState"
|
||||
aria-label={localize('com_nav_save_badges_state')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function SaveDraft({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const [showThinking, setShowThinking] = useAtom(showThinkingAtom);
|
||||
const [showThinking, setSaveDrafts] = useRecoilState<boolean>(store.showThinking);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setShowThinking(value);
|
||||
setSaveDrafts(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export default function SaveDraft({
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="showThinking"
|
||||
aria-label={localize('com_nav_show_thinking')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,12 @@ import { useLocalize } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
|
||||
function ImportConversations() {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
|
||||
const localize = useLocalize();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
@@ -51,8 +53,7 @@ function ImportConversations() {
|
||||
const handleFileUpload = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
|
||||
const maxFileSize = startupConfig?.conversationImportMaxFileSize;
|
||||
const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize;
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
const size = (maxFileSize / (1024 * 1024)).toFixed(2);
|
||||
showToast({
|
||||
@@ -75,7 +76,7 @@ function ImportConversations() {
|
||||
});
|
||||
}
|
||||
},
|
||||
[uploadFile, showToast, localize, queryClient],
|
||||
[uploadFile, showToast, localize, startupConfig],
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
@@ -118,11 +119,16 @@ function ImportConversations() {
|
||||
aria-labelledby="import-conversation-label"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="mr-1 w-4" />
|
||||
<>
|
||||
<Spinner className="mr-1 w-4" />
|
||||
<span>{localize('com_ui_importing')}</span>
|
||||
</>
|
||||
) : (
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
<>
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</>
|
||||
)}
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import { TrashIcon, MessageSquare } from 'lucide-react';
|
||||
import {
|
||||
OGDialog,
|
||||
useToastContext,
|
||||
@@ -13,89 +10,162 @@ import {
|
||||
useMediaQuery,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
TooltipAnchor,
|
||||
DataTable,
|
||||
Spinner,
|
||||
Button,
|
||||
Label,
|
||||
} from '@librechat/client';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatDate } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
import { formatDate, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DEFAULT_PARAMS: SharedLinksListParams = {
|
||||
pageSize: PAGE_SIZE,
|
||||
pageSize: 25,
|
||||
isPublic: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
type SortKey = 'createdAt' | 'title';
|
||||
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||
|
||||
const defaultSort: SortingState = [
|
||||
{
|
||||
id: 'createdAt',
|
||||
desc: true,
|
||||
},
|
||||
];
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
className?: string;
|
||||
desktopOnly?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SharedLinks() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const isSearchEnabled = useRecoilValue(store.search);
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
useSharedLinksQuery(queryParams, {
|
||||
enabled: isOpen,
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
keepPreviousData: true,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
const [allKnownLinks, setAllKnownLinks] = useState<SharedLinkItem[]>([]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setAllKnownLinks([]);
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
search: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
}));
|
||||
}, []);
|
||||
const handleSortingChange = useCallback(
|
||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
const coerced = next;
|
||||
const primary = coerced[0];
|
||||
|
||||
if (data?.pages) {
|
||||
const currentFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||
setAllKnownLinks(currentFlattened);
|
||||
}
|
||||
|
||||
setQueryParams((p) => {
|
||||
let sortBy: SortKey;
|
||||
let sortDirection: 'asc' | 'desc';
|
||||
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
sortBy = primary.id;
|
||||
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = 'createdAt';
|
||||
sortDirection = 'desc';
|
||||
}
|
||||
|
||||
const newParams = {
|
||||
...p,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
};
|
||||
|
||||
return newParams;
|
||||
});
|
||||
|
||||
return coerced;
|
||||
});
|
||||
},
|
||||
[setQueryParams, data?.pages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
if (!data?.pages) return;
|
||||
|
||||
const allLinks = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
const newFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||
|
||||
const toAdd = newFlattened.filter(
|
||||
(link: SharedLinkItem) => !allKnownLinks.some((known) => known.shareId === link.shareId),
|
||||
);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
setAllKnownLinks((prev) => [...prev, ...toAdd]);
|
||||
}
|
||||
|
||||
return data.pages.flatMap((page) => page.links.filter(Boolean));
|
||||
}, [data?.pages]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
const primary = sorting[0];
|
||||
if (!primary || allKnownLinks.length === 0) return allKnownLinks;
|
||||
|
||||
return [...allKnownLinks].sort((a: SharedLinkItem, b: SharedLinkItem) => {
|
||||
let compare: number;
|
||||
if (primary.id === 'createdAt') {
|
||||
const aDate = new Date(a.createdAt || 0);
|
||||
const bDate = new Date(b.createdAt || 0);
|
||||
compare = aDate.getTime() - bDate.getTime();
|
||||
} else if (primary.id === 'title') {
|
||||
compare = (a.title || '').localeCompare(b.title || '');
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return primary.desc ? -compare : compare;
|
||||
});
|
||||
}, [allKnownLinks, sorting]);
|
||||
|
||||
const deleteMutation = useDeleteSharedLinkMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: (data, variables) => {
|
||||
const { shareId } = variables;
|
||||
setAllKnownLinks((prev) => prev.filter((link) => link.shareId !== shareId));
|
||||
showToast({
|
||||
message: localize('com_ui_shared_link_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteRow(null);
|
||||
await refetch();
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Delete error:', error);
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
@@ -103,94 +173,47 @@ export default function SharedLinks() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (selectedRows: SharedLinkItem[]) => {
|
||||
const validRows = selectedRows.filter(
|
||||
(row) => typeof row.shareId === 'string' && row.shareId.length > 0,
|
||||
);
|
||||
|
||||
if (validRows.length === 0) {
|
||||
showToast({
|
||||
message: localize('com_ui_no_valid_items'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const row of validRows) {
|
||||
await deleteMutation.mutateAsync({ shareId: row.shareId });
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize(
|
||||
validRows.length === 1
|
||||
? 'com_ui_shared_link_delete_success'
|
||||
: 'com_ui_shared_link_bulk_delete_success',
|
||||
),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete shared links:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_bulk_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
[deleteMutation, showToast, localize],
|
||||
);
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (hasNextPage !== true || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
}
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteRow, handleDelete]);
|
||||
const effectiveIsLoading = isLoading && displayData.length === 0;
|
||||
const effectiveIsFetching = isFetchingNextPage;
|
||||
|
||||
const columns = useMemo(
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!deleteRow?.shareId) {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ shareId: deleteRow.shareId });
|
||||
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||
|
||||
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'title';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const link = row as SharedLinkItem;
|
||||
return link.title;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_name')}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { title, shareId } = row.original;
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, shareId } = link;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/share/${shareId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block truncate text-blue-500 hover:underline"
|
||||
title={title}
|
||||
className="flex items-center truncate text-blue-500 hover:underline"
|
||||
aria-label={localize('com_ui_open_link', { 0: title })}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
@@ -198,112 +221,123 @@ export default function SharedLinks() {
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: '35%',
|
||||
mobileSize: '50%',
|
||||
className: 'min-w-[150px] flex-1',
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'createdAt';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const link = row as SharedLinkItem;
|
||||
return link.createdAt;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_date')}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
return formatDate(link.createdAt?.toString() ?? '', isSmallScreen);
|
||||
},
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: '10%',
|
||||
mobileSize: '20%',
|
||||
className: 'w-32 sm:w-40',
|
||||
desktopOnly: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
id: 'actions',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => null,
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
size: '7%',
|
||||
mobileSize: '25%',
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, conversationId } = link;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_view_source')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${conversationId}`, '_blank');
|
||||
}}
|
||||
aria-label={localize('com_ui_view_source_conversation', { 0: title })}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setDeleteRow(link);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={localize('com_ui_delete_link_title', { 0: title })}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${row.original.conversationId}`, '_blank');
|
||||
}}
|
||||
aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
|
||||
>
|
||||
<MessageSquare className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={`${localize('com_ui_delete')} - ${row.original.title || localize('com_ui_untitled')}`}
|
||||
>
|
||||
<TrashIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-24',
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize, queryParams, handleSort],
|
||||
[isSmallScreen, localize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
|
||||
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button aria-labelledby="shared-links-label" variant="outline">
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_my_files')}
|
||||
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
|
||||
>
|
||||
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allLinks}
|
||||
onDelete={handleDelete}
|
||||
filterColumn="title"
|
||||
data={displayData}
|
||||
isLoading={effectiveIsLoading}
|
||||
isFetching={effectiveIsFetching}
|
||||
config={{
|
||||
skeleton: { count: 11 },
|
||||
search: {
|
||||
filterColumn: 'title',
|
||||
enableSearch: true,
|
||||
debounce: 300,
|
||||
},
|
||||
selection: {
|
||||
enableRowSelection: false,
|
||||
showCheckboxes: false,
|
||||
},
|
||||
}}
|
||||
filterValue={searchValue}
|
||||
onFilterChange={handleSearchChange}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
showCheckboxes={false}
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
isLoading={isLoading}
|
||||
enableSearch={isSearchEnabled}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
@@ -311,17 +345,15 @@ export default function SharedLinks() {
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_shared_link')}
|
||||
className="max-w-[450px]"
|
||||
className="w-11/12 max-w-md"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
|
||||
@@ -1,26 +1,406 @@
|
||||
import { useState } from 'react';
|
||||
import { OGDialogTemplate, OGDialog, OGDialogTrigger, Button } from '@librechat/client';
|
||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { TrashIcon, ArchiveRestore } from 'lucide-react';
|
||||
import { useQueryClient, InfiniteData } from '@tanstack/react-query';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
OGDialogTemplate,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Label,
|
||||
TooltipAnchor,
|
||||
Spinner,
|
||||
useToastContext,
|
||||
useMediaQuery,
|
||||
DataTable,
|
||||
type TableColumn,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import {
|
||||
useArchiveConvoMutation,
|
||||
useConversationsInfiniteQuery,
|
||||
useDeleteConversationMutation,
|
||||
} from '~/data-provider';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatDate, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ArchivedChats() {
|
||||
const DEFAULT_PARAMS = {
|
||||
isArchived: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
} as const satisfies ConversationListParams;
|
||||
|
||||
type SortKey = 'createdAt' | 'title';
|
||||
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||
|
||||
const defaultSort: SortingState = [
|
||||
{
|
||||
id: 'createdAt',
|
||||
desc: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper: remove a conversation from all infinite queries whose key starts with the provided root
|
||||
*/
|
||||
function removeConversationFromInfinite(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
rootKey: string,
|
||||
conversationId: string,
|
||||
) {
|
||||
const queries = queryClient.getQueryCache().findAll([rootKey], { exact: false });
|
||||
for (const query of queries) {
|
||||
queryClient.setQueryData<
|
||||
InfiniteData<{ conversations: TConversation[]; nextCursor?: string | null }>
|
||||
>(query.queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
conversations: page.conversations.filter((c) => c.conversationId !== conversationId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function ArchivedChatsTable() {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { showToast } = useToastContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<TConversation | null>(null);
|
||||
const [unarchivingId, setUnarchivingId] = useState<string | null>(null);
|
||||
|
||||
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useConversationsInfiniteQuery(queryParams, {
|
||||
enabled: isOpen,
|
||||
keepPreviousData: false,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSortingChange = useCallback(
|
||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
const primary = next[0];
|
||||
setQueryParams((p) => {
|
||||
let sortBy: SortKey = 'createdAt';
|
||||
let sortDirection: 'asc' | 'desc' = 'desc';
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
sortBy = primary.id;
|
||||
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
};
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const flattenedConversations = useMemo(
|
||||
() => data?.pages?.flatMap((page) => page?.conversations?.filter(Boolean) ?? []) ?? [],
|
||||
[data?.pages],
|
||||
);
|
||||
|
||||
const unarchiveMutation = useArchiveConvoMutation({
|
||||
onSuccess: (_res, variables) => {
|
||||
const { conversationId } = variables;
|
||||
if (conversationId) {
|
||||
removeConversationFromInfinite(
|
||||
queryClient,
|
||||
QueryKeys.archivedConversations,
|
||||
conversationId,
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries([QueryKeys.allConversations]);
|
||||
setUnarchivingId(null);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_unarchive_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
setUnarchivingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
const { conversationId } = variables;
|
||||
if (conversationId) {
|
||||
removeConversationFromInfinite(
|
||||
queryClient,
|
||||
QueryKeys.archivedConversations,
|
||||
conversationId,
|
||||
);
|
||||
}
|
||||
showToast({
|
||||
message: localize('com_ui_archived_conversation_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_archive_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const effectiveIsLoading = isLoading;
|
||||
const effectiveIsFetching = isFetchingNextPage;
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!deleteRow?.conversationId) {
|
||||
showToast({
|
||||
message: localize('com_ui_convo_delete_error'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ conversationId: deleteRow.conversationId });
|
||||
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||
|
||||
const handleUnarchive = useCallback(
|
||||
(conversationId: string) => {
|
||||
setUnarchivingId(conversationId);
|
||||
unarchiveMutation.mutate(
|
||||
{ conversationId, isArchived: false },
|
||||
{ onSettled: () => setUnarchivingId(null) },
|
||||
);
|
||||
},
|
||||
[unarchiveMutation],
|
||||
);
|
||||
|
||||
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const convo = row as TConversation;
|
||||
return convo.title;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_nav_archive_name')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const convo = row.original as TConversation;
|
||||
const { conversationId, title } = convo;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<MinimalIcon
|
||||
endpoint={convo.endpoint}
|
||||
size={28}
|
||||
isCreatedByUser={false}
|
||||
iconClassName="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a
|
||||
href={`/c/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center truncate underline"
|
||||
aria-label={localize('com_ui_open_conversation', { 0: title })}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'min-w-[150px] flex-1',
|
||||
isRowHeader: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const convo = row as TConversation;
|
||||
return convo.createdAt;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_nav_archive_created_at')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const convo = row.original as TConversation;
|
||||
return formatDate(convo.createdAt?.toString() ?? '', isSmallScreen);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-32 sm:w-40',
|
||||
desktopOnly: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
accessorFn: () => null,
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const convo = row.original as TConversation;
|
||||
const { title } = convo;
|
||||
const isRowUnarchiving = unarchivingId === convo.conversationId;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unarchive')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 w-9 p-0 hover:bg-surface-hover md:h-8 md:w-8"
|
||||
onClick={() => {
|
||||
const conversationId = convo.conversationId;
|
||||
if (!conversationId) return;
|
||||
handleUnarchive(conversationId);
|
||||
}}
|
||||
disabled={isRowUnarchiving}
|
||||
aria-label={localize('com_ui_unarchive_conversation_title', { 0: title })}
|
||||
>
|
||||
{isRowUnarchiving ? <Spinner /> : <ArchiveRestore className="size-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-9 w-9 p-0 md:h-8 md:w-8"
|
||||
onClick={() => {
|
||||
setDeleteRow(convo);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={localize('com_ui_delete_conversation_title', { 0: title })}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-24',
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize, handleUnarchive, unarchivingId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_archived_chats')}</div>
|
||||
<Label htmlFor="archived-chats-button" className="text-sm font-medium">
|
||||
{localize('com_nav_archived_chats')}
|
||||
</Label>
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
<Button
|
||||
id="archived-chats-button"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_manage_archived_chats')}
|
||||
>
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={flattenedConversations}
|
||||
isLoading={effectiveIsLoading}
|
||||
isFetching={effectiveIsFetching}
|
||||
config={{
|
||||
skeleton: { count: 11 },
|
||||
search: {
|
||||
filterColumn: 'title',
|
||||
enableSearch: true,
|
||||
debounce: 300,
|
||||
},
|
||||
selection: {
|
||||
enableRowSelection: false,
|
||||
showCheckboxes: false,
|
||||
},
|
||||
}}
|
||||
filterValue={searchValue}
|
||||
onFilterChange={handleSearchChange}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_nav_archived_chats')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_archived_chats')}
|
||||
className="w-11/12 max-w-md"
|
||||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
|
||||
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
|
||||
}`,
|
||||
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Label,
|
||||
TooltipAnchor,
|
||||
Spinner,
|
||||
DataTable,
|
||||
useToastContext,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import {
|
||||
useArchiveConvoMutation,
|
||||
useConversationsInfiniteQuery,
|
||||
useDeleteConversationMutation,
|
||||
} from '~/data-provider';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { formatDate } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const DEFAULT_PARAMS: ConversationListParams = {
|
||||
isArchived: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
export default function ArchivedChatsTable({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { showToast } = useToastContext();
|
||||
const isSearchEnabled = useRecoilValue(store.search);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
||||
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
useConversationsInfiniteQuery(queryParams, {
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
|
||||
const allConversations = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
||||
}, [data?.pages]);
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
await refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_archive_delete_error') as string,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const unarchiveMutation = useArchiveConvoMutation({
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_unarchive_error') as string,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'title';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_nav_archive_name')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { conversationId, title } = row.original;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 truncate"
|
||||
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
|
||||
>
|
||||
<MinimalIcon
|
||||
endpoint={row.original.endpoint}
|
||||
size={28}
|
||||
isCreatedByUser={false}
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<span className="underline">{title}</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: isSmallScreen ? '70%' : '50%',
|
||||
mobileSize: '70%',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'createdAt';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_nav_archive_created_at')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: isSmallScreen ? '30%' : '35%',
|
||||
mobileSize: '30%',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const conversation = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unarchive')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() =>
|
||||
unarchiveMutation.mutate({
|
||||
conversationId: conversation.conversationId,
|
||||
isArchived: false,
|
||||
})
|
||||
}
|
||||
title={localize('com_ui_unarchive')}
|
||||
disabled={unarchiveMutation.isLoading}
|
||||
>
|
||||
{unarchiveMutation.isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<ArchiveRestore className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteConversation(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_delete')}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: '15%',
|
||||
mobileSize: '25%',
|
||||
},
|
||||
},
|
||||
],
|
||||
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
filterColumn="title"
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isLoading}
|
||||
showCheckboxes={false}
|
||||
enableSearch={isSearchEnabled}
|
||||
/>
|
||||
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
title={localize('com_ui_delete_confirm') + ' ' + (deleteConversation?.title ?? '')}
|
||||
className="w-11/12 max-w-md"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteConversation?.title}</strong>
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<Button aria-label="cancel" variant="outline" onClick={() => setIsDeleteOpen(false)}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
deleteMutation.mutate({
|
||||
conversationId: deleteConversation?.conversationId ?? '',
|
||||
})
|
||||
}
|
||||
disabled={deleteMutation.isLoading}
|
||||
>
|
||||
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WritableAtom, useAtom } from 'jotai';
|
||||
import { RecoilState, useRecoilState } from 'recoil';
|
||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -7,7 +6,7 @@ type LocalizeFn = ReturnType<typeof useLocalize>;
|
||||
type LocalizeKey = Parameters<LocalizeFn>[0];
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
stateAtom: RecoilState<boolean> | WritableAtom<boolean, [boolean], void>;
|
||||
stateAtom: RecoilState<boolean>;
|
||||
localizationKey: LocalizeKey;
|
||||
hoverCardText?: LocalizeKey;
|
||||
switchId: string;
|
||||
@@ -17,18 +16,13 @@ interface ToggleSwitchProps {
|
||||
strongLabel?: boolean;
|
||||
}
|
||||
|
||||
function isRecoilState<T>(atom: unknown): atom is RecoilState<T> {
|
||||
return atom != null && typeof atom === 'object' && 'key' in atom;
|
||||
}
|
||||
|
||||
const RecoilToggle: React.FC<
|
||||
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: RecoilState<boolean> }
|
||||
> = ({
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
stateAtom,
|
||||
localizationKey,
|
||||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
showSwitch = true,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
@@ -42,83 +36,29 @@ const RecoilToggle: React.FC<
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JotaiToggle: React.FC<
|
||||
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: WritableAtom<boolean, [boolean], void> }
|
||||
> = ({
|
||||
stateAtom,
|
||||
localizationKey,
|
||||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
const [switchState, setSwitchState] = useAtom(stateAtom);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setSwitchState(value);
|
||||
onCheckedChange?.(value);
|
||||
};
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = (props) => {
|
||||
const { stateAtom, showSwitch = true } = props;
|
||||
|
||||
if (!showSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isRecoil = isRecoilState(stateAtom);
|
||||
|
||||
if (isRecoil) {
|
||||
return <RecoilToggle {...props} stateAtom={stateAtom as RecoilState<boolean>} />;
|
||||
}
|
||||
|
||||
return <JotaiToggle {...props} stateAtom={stateAtom as WritableAtom<boolean, [boolean], void>} />;
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleSwitch;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user